diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 926d834..6751f95 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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 diff --git a/README.md b/README.md index 9d1b86a..7234773 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 2dbc1f9..abbd212 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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._ diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md new file mode 100644 index 0000000..5be8279 --- /dev/null +++ b/docs/EMBEDDING.md @@ -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. diff --git a/docs/packkit-core.js b/docs/packkit-core.js index 0f16331..81d07a8 100644 --- a/docs/packkit-core.js +++ b/docs/packkit-core.js @@ -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"; @@ -327,23 +339,24 @@ 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])]; @@ -351,6 +364,7 @@ function deepMerge(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; @@ -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", @@ -3110,9 +3144,11 @@ export { PRESET_ALIASES, PRESET_INFO, PRESET_NAMES, + assemble, defaultConfig, fromPreset, generate, + generateTracked, normalizeConfig, resolvePreset }; diff --git a/package.json b/package.json index 2f96fc1..ceba7c9 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,29 @@ "bin": { "create-packkit": "bin/cli.js" }, + "types": "./types/index.d.ts", "exports": { - ".": "./src/core/index.js", + ".": { + "types": "./types/index.d.ts", + "default": "./src/index.js" + }, "./core": "./src/core/index.js", + "./embedded": { + "types": "./types/embedded.d.ts", + "default": "./src/embedded/index.js" + }, + "./writer": { + "types": "./types/writer.d.ts", + "default": "./src/embedded/writer.js" + }, "./cli": "./src/cli/index.js", - "./scaffold": "./src/cli/write.js" + "./scaffold": "./src/cli/write.js", + "./package.json": "./package.json" }, "files": [ "bin", "src", + "types", "README.md", "LICENSE", "llms.txt" diff --git a/scripts/integration.mjs b/scripts/integration.mjs index bc93e03..35e9f72 100644 --- a/scripts/integration.mjs +++ b/scripts/integration.mjs @@ -13,12 +13,16 @@ import { spawnSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const repo = fileURLToPath(new URL('..', import.meta.url)); -const argv = process.argv.slice(2); +let argv = process.argv.slice(2); +// --embedded generates through the embedded API instead of the CLI, so the same +// install/build/test/lint checks also cover the programmatic path a host uses. +const embedded = argv.includes('--embedded'); +argv = argv.filter((a) => a !== '--embedded'); if (!argv.length) { - console.error('usage: node scripts/integration.mjs [flags...]'); + console.error('usage: node scripts/integration.mjs [flags...] [--embedded]'); process.exit(2); } -const label = argv.join(' '); +const label = (embedded ? 'embedded ' : '') + argv.join(' '); const work = mkdtempSync(join(tmpdir(), 'packkit-int-')); const app = join(work, 'app'); @@ -28,12 +32,46 @@ function step(cmd, args, opts = {}) { return spawnSync(cmd, args, { stdio: 'inherit', ...opts }).status === 0; } -// 1) generate via the real CLI (non-interactive) -if (!step('node', [join(repo, 'bin/cli.js'), ...argv, 'app', '--no-git', '--no-install'], { cwd: work })) { +// 1) generate — via the CLI, or (for --embedded) the programmatic API, which +// also adds a deployment workflow, checks the deployment contract, and confirms +// the definition round-trips to an identical digest. +if (embedded) { + if (!(await generateEmbedded())) process.exit(1); +} else if (!step('node', [join(repo, 'bin/cli.js'), ...argv, 'app', '--no-git', '--no-install'], { cwd: work })) { console.error(`\n✗ [${label}] generation failed`); process.exit(1); } +async function generateEmbedded() { + const { createProject, extendProject, exportProjectDefinition, createProjectFromDefinition, calculateProjectDigest } = + await import(join(repo, 'src/embedded/index.js')); + const { writeGeneratedProject } = await import(join(repo, 'src/embedded/writer.js')); + + const preset = argv[0]; + const project = createProject({ preset, name: 'app', overrides: { install: false, gitInit: false } }); + const fatal = project.diagnostics.filter((d) => d.severity === 'error'); + if (fatal.length) { + console.error(`\n✗ [${label}] createProject reported errors:`, fatal); + return false; + } + const extended = extendProject(project, { + files: { '.platform/deploy.yml': `# generated deploy contract\n${JSON.stringify(project.deploymentContract, null, 2)}\n` }, + }); + // Reproducibility: a stored definition must rebuild byte-for-byte. + const recreated = createProjectFromDefinition(exportProjectDefinition(extended)); + if (calculateProjectDigest(extended) !== calculateProjectDigest(recreated)) { + console.error(`\n✗ [${label}] digest changed after definition round-trip`); + return false; + } + if (!recreated.deploymentContract?.type) { + console.error(`\n✗ [${label}] missing deployment contract`); + return false; + } + const res = await writeGeneratedProject({ project: recreated, destination: app }); + console.log(`✓ embedded: wrote ${res.writtenFiles.length} files · contract ${recreated.deploymentContract.type} · digest stable`); + return true; +} + // Real usage runs `git init` before install; some hook `prepare` scripts // (e.g. lefthook install) require a .git directory. spawnSync('git', ['init', '--quiet'], { cwd: app }); diff --git a/src/core/index.js b/src/core/index.js index 8879747..542bfd8 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -22,23 +22,49 @@ export function fromPreset(name, overrides = {}) { return cfg; } -/** Turn a config into a complete set of files. */ -export function generate(input) { - const cfg = normalizeConfig(input); - if (cfg.monorepo) return buildMonorepo(cfg); - +/** + * Run every active feature, collecting its files and package.json fragment. + * Returns the raw material — file map, the fragments in contribution order, + * and which feature produced each file — so callers that need provenance (the + * embedded API's collision detection) get it without a second pass, while + * `generate` folds it into the same output as before. + */ +export function assemble(cfg) { const files = {}; + const fileSources = {}; + const fragments = []; let pkg = {}; for (const feat of features) { 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) { + fragments.push({ source: feat.id, pkg: out.pkg }); + pkg = deepMerge(pkg, out.pkg); } - if (out.pkg) pkg = deepMerge(pkg, out.pkg); } + return { files, fileSources, fragments, pkg }; +} +/** + * Generate, keeping the provenance assemble() produced. One assembly pass feeds + * both the public files and the embedded API's conflict diagnostics, so the + * bytes callers get and the provenance they inspect come from the same run. + */ +export function generateTracked(input, diagnostics = null) { + const cfg = normalizeConfig(input, diagnostics); + if (cfg.monorepo) { + // The monorepo generator is a separate path with no per-feature fragments. + return { ...buildMonorepo(cfg), fileSources: {}, fragments: [] }; + } + + const { files, fileSources, fragments, pkg } = assemble(cfg); files['package.json'] = toJson(finalizePackageJson(pkg)); files['packkit.json'] = provenance(cfg); @@ -47,9 +73,17 @@ export function generate(input) { files, postCommands: postCommands(cfg), summary: summarize(cfg, files), + fileSources, + fragments, }; } +/** Turn a config into a complete set of files. */ +export function generate(input) { + const { config, files, postCommands, summary } = generateTracked(input); + return { config, files, postCommands, summary }; +} + function postCommands(cfg) { const install = { npm: 'npm install', diff --git a/src/core/options.js b/src/core/options.js index bdb8e26..a4a4a8a 100644 --- a/src/core/options.js +++ b/src/core/options.js @@ -260,21 +260,42 @@ export function defaultConfig() { return cfg; } -/** Merge partial input over the defaults and coerce a few fields. */ -export function normalizeConfig(input = {}) { +/** + * Merge partial input over the defaults and coerce a few fields. + * + * Pass a `diagnostics` array to have every coercion that overrides an + * explicitly-supplied value recorded there (the embedded API surfaces these so + * a host application learns when Packkit changed its requested config, instead + * of the change happening silently). Existing callers omit it and see identical + * behavior — the coercions run exactly the same either way. + */ +export function normalizeConfig(input = {}, diagnostics = null) { const cfg = { ...defaultConfig(), ...input }; + const requested = new Set(Object.keys(input)); - // A CLI target needs an executable build; JS `strict` isn't a thing, etc. - if (!Array.isArray(cfg.target) || cfg.target.length === 0) cfg.target = ['library']; - if (!Array.isArray(cfg.workflows)) cfg.workflows = []; + // Record a coercion only when it actually changes an explicitly-requested + // value — a field left at its default that "changes" to the same default is + // not news. Derived helper flags (isTs, hasApp…) go through plain assignment + // below; they are computed, not overrides, so they never emit a diagnostic. + 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' }); + } + }; - // Minify needs a bundler. - if (cfg.bundler === 'none') cfg.minify = false; - // Coverage only makes sense with a test runner that supports it. - if (cfg.test === 'none' || cfg.test === 'node') cfg.coverage = false; - // Codecov workflow implies coverage. - if (cfg.workflows.includes('codecov')) cfg.coverage = true; - // npm-publish + changesets are complementary; nothing to coerce, just noted. + // A target is required; default it if missing. + 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'; @@ -284,7 +305,7 @@ export function normalizeConfig(input = {}) { cfg.hasApp = cfg.target.includes('app'); // A component-framework package that isn't an app is a library. 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'; @@ -306,31 +327,31 @@ export function normalizeConfig(input = {}) { cfg.hasBuild = cfg.viteBuild || (!cfg.svelteLib && (cfg.bundler !== 'none' || cfg.isTs)); // Apps aren't published packages. - 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'; // Storybook only applies to component libraries. - if (!cfg.hasFramework || cfg.hasApp || !cfg.hasLibrary) cfg.storybook = 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.'); // Playwright E2E only applies to app targets. - if (!cfg.hasApp) cfg.e2e = false; + if (!cfg.hasApp) coerce('e2e', false, 'E2E_REQUIRES_APP', 'End-to-end tests were disabled because they only apply to an app target.'); // A monorepo is its own generation path (see buildMonorepo); it has a build. if (cfg.monorepo) cfg.hasBuild = true; cfg.publishable = (cfg.hasLibrary || cfg.hasCli) && !cfg.hasApp && !cfg.hasService; // Package-correctness checks only make sense for a publishable package. - if (!cfg.publishable) cfg.pkgChecks = false; + if (!cfg.publishable) coerce('pkgChecks', false, 'PKG_CHECKS_REQUIRES_PUBLISHABLE', 'Package-correctness checks were disabled because this project is not published to npm.'); // Sourcemaps + shipped source only matter for a published package. - if (!cfg.publishable) cfg.sourcemaps = false; + if (!cfg.publishable) coerce('sourcemaps', false, 'SOURCEMAPS_REQUIRES_PUBLISHABLE', 'Sourcemaps were disabled because this project is not published to npm.'); // A bundle-size budget needs a published package with a built entry. - if (!(cfg.publishable && cfg.hasBuild)) cfg.sizeLimit = false; + 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.'); // Env validation is for server-side runtimes (services / CLIs), not libs/apps. - if (!(cfg.hasService || cfg.hasCli)) cfg.env = false; + 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.'); // Canary snapshots ride on the Changesets flow. - if (cfg.release !== 'changesets') cfg.canary = false; + if (cfg.release !== 'changesets') coerce('canary', false, 'CANARY_REQUIRES_CHANGESETS', 'Canary releases were disabled because they require the Changesets release flow.'); // JSR is TypeScript-first, ESM, and for plain (non-framework) libraries. - if (!(cfg.isTs && cfg.hasLibrary && !cfg.hasFramework && !cfg.hasApp)) cfg.jsr = false; + 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; } diff --git a/src/core/render.js b/src/core/render.js index ba0bc3d..cfb7406 100644 --- a/src/core/render.js +++ b/src/core/render.js @@ -9,6 +9,11 @@ export function render(template, vars = {}) { }); } +// Keys that would let a merged-in object reach an object's prototype. The +// embedded API deep-merges host-supplied data (extension package.json), so +// these are skipped defensively even though the merge is immutable. +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + /** Deep-merge plain objects (arrays are concatenated + de-duped). Used to fold * each feature's package.json fragment into the accumulator. */ export function deepMerge(target, source) { @@ -18,6 +23,7 @@ export function deepMerge(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; diff --git a/src/embedded/contract.js b/src/embedded/contract.js new file mode 100644 index 0000000..d79ec2c --- /dev/null +++ b/src/embedded/contract.js @@ -0,0 +1,64 @@ +// Provider-neutral deployment contract. +// +// A host application deploying a generated project needs to know how to build +// and run it — but Packkit must not know or care whether that host is Vercel, +// a Kubernetes cluster, or a Raspberry Pi. So this describes build/runtime +// requirements in provider-agnostic terms, derived purely from the resolved +// config. No AWS/Netlify/Vercel/Cloudflare/GitHub fields, by design. + +/** + * Derive the deployment contract from a resolved config. + * @returns {import('./index.js').DeploymentContract} + */ +export function deriveDeploymentContract(cfg) { + const run = (script) => (cfg.packageManager === 'npm' ? `npm run ${script}` : `${cfg.packageManager} ${script}`); + const start = cfg.packageManager === 'npm' ? 'npm start' : `${cfg.packageManager} start`; + + if (cfg.hasService) { + // The generated server reads PORT but defaults to 3000, so PORT is optional, + // not required — `port` communicates the default and the `PORT` env var + // overrides it. `healthCheckPath` is only asserted because every service + // framework Packkit generates (Hono/Fastify/Express) defines /health. + return prune({ + type: 'node-service', + buildCommand: cfg.hasBuild ? run('build') : undefined, + startCommand: start, + port: 3000, + healthCheckPath: '/health', + requiredEnvironmentVariables: [], + }); + } + + if (cfg.hasApp) { + return prune({ + type: 'static', + buildCommand: run('build'), + // Vite's default build output. + outputDirectory: 'dist', + }); + } + + if (cfg.hasCli) { + return prune({ + type: 'cli', + buildCommand: cfg.hasBuild ? run('build') : undefined, + }); + } + + return prune({ + type: 'library', + buildCommand: cfg.hasBuild ? run('build') : undefined, + }); +} + +// Drop undefined fields and empty required-env arrays so the contract stays +// minimal and deterministic — the same config always yields the same object. +function prune(contract) { + const out = {}; + for (const [k, v] of Object.entries(contract)) { + if (v === undefined) continue; + if (k === 'requiredEnvironmentVariables' && Array.isArray(v) && v.length === 0) continue; + out[k] = v; + } + return out; +} diff --git a/src/embedded/index.js b/src/embedded/index.js new file mode 100644 index 0000000..d727240 --- /dev/null +++ b/src/embedded/index.js @@ -0,0 +1,451 @@ +// Embedded API — the supported entry point for a Node application that wants +// to use Packkit as a project-generation engine. +// +// Everything here is pure and side-effect free except the writer (separate +// module). No prompts, no installs, no git, no network, no command execution. +// A host calls createProject() to generate in memory, extendProject() to add +// its own deployment files, and writeGeneratedProject() when it wants disk. + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; + +import { + generateTracked, + normalizeConfig, + resolvePreset, + PRESETS, + OPTIONS, +} from '../core/index.js'; +import { finalizePackageJson } from '../core/pkg.js'; +import { deepMerge, toJson } from '../core/render.js'; +import { validateRelativePath, validatePathMap } from './paths.js'; +import { analyzePkgFragments } from './pkg-merge.js'; +import { deriveDeploymentContract } from './contract.js'; + +export { deriveDeploymentContract }; + +// Bumped when the shape of PackkitProjectDefinition changes incompatibly. +export const SCHEMA_VERSION = 2; + +// Resource ceilings for definitions loaded from untrusted stores (a database, +// an upload, a queue). Generous enough for any real project, small enough to +// refuse a hostile blob before it reaches the filesystem. +const MAX_DEFINITION_FILES = 5000; +const MAX_DEFINITION_BYTES = 50_000_000; + +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** Non-OPTIONS config keys the pipeline sets itself; not "unknown". */ +const KNOWN_EXTRA_KEYS = new Set(['preset', 'generatorVersion']); + +// Fields where a host override silently changing an existing value is worth a +// diagnostic (matches pkg-merge's protected set + dependency maps). +const PROTECTED_PKG_FIELDS = new Set(['scripts', 'exports', 'bin', 'main', 'module', 'types', 'files', 'engines', 'packageManager']); +const DEP_MAPS = new Set(['dependencies', 'devDependencies', 'peerDependencies']); + +// Replay state lives off to the side, keyed by the project object, so the +// public GeneratedProject stays clean (no leaking `_extensions`), immutable to +// consumers, and never accidentally serialized into logs or API responses. +const extensionState = new WeakMap(); + +/** Thrown when a config or definition cannot produce a valid project. */ +export class PackkitValidationError extends Error { + constructor(message, diagnostics) { + super(message); + this.name = 'PackkitValidationError'; + this.code = 'PACKKIT_VALIDATION_FAILED'; + this.diagnostics = diagnostics; + } +} + +let cachedVersion; +function packkitVersion() { + if (cachedVersion) return cachedVersion; + try { + const url = new URL('../../package.json', import.meta.url); + cachedVersion = JSON.parse(readFileSync(fileURLToPath(url), 'utf8')).version; + } catch { + cachedVersion = '0.0.0'; + } + return cachedVersion; +} + +/** + * 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 = {}) { + if (!input || typeof input !== 'object') throw new TypeError('createProject expects an input object.'); + + const merged = { ...(input.config || {}), ...(input.overrides || {}) }; + if (input.name != null) merged.name = input.name; + + const preErrors = validateInput(merged); + if (preErrors.length) { + throw new PackkitValidationError('The configuration is not valid; see error.diagnostics.', preErrors); + } + + const diagnostics = [...unknownOptionDiagnostics(merged)]; + + let canonicalPreset; + if (input.preset) { + canonicalPreset = resolvePreset(input.preset); + if (!canonicalPreset) { + throw new PackkitValidationError(`Unknown preset "${input.preset}".`, [ + { severity: 'error', code: 'UNKNOWN_PRESET', field: 'preset', message: `Unknown preset "${input.preset}".`, source: 'validate' }, + ]); + } + } + // Normalize ONCE, with the collector, over the raw preset+overrides — a second + // pass would see values already settled and report nothing. The preset is + // folded into `raw` (not set afterwards) so it reaches provenance during this + // pass; otherwise a replayed definition, whose config carries the preset, would + // bake it into packkit.json while the original did not — and the digests diverge. + const raw = canonicalPreset + ? { ...PRESETS[canonicalPreset], ...merged, preset: canonicalPreset } + : merged; + const { config, files, summary, fileSources, fragments } = generateTracked({ ...raw, generatorVersion: packkitVersion() }, diagnostics); + + for (const [path, sources] of Object.entries(fileSources)) { + if (sources.length > 1) { + diagnostics.push({ + severity: 'warning', + code: 'FEATURE_FILE_COLLISION', + field: path, + message: `"${path}" was written by more than one feature (${sources.join(', ')}); the last one wins.`, + source: 'assemble', + }); + } + } + diagnostics.push(...analyzePkgFragments(fragments).diagnostics); + + return finish({ + config, + files, + summary, + diagnostics, + metadata: { packkitVersion: packkitVersion(), schemaVersion: SCHEMA_VERSION, preset: config.preset }, + deploymentContract: deriveDeploymentContract(config), + }, { files: {}, packageJson: {} }); +} + +/** + * Return a NEW project with the extension's files and package.json fields + * layered on. Never mutates `project`. Extension file paths and contents are + * validated; collisions with existing files follow collisionPolicy ('error' + * by default), and host overrides of existing package fields are reported. + */ +export function extendProject(project, extension = {}, internal = {}) { + assertProject(project); + // Internal: a path→mode map lets definition replay preserve the ORIGINAL + // add/replace intent instead of re-deriving it against the current base. + // Without it, an `add` that now collides would be re-recorded as `replace`, + // and the drift warning would vanish after one load-and-save cycle. + const storedModes = internal.fileModes || null; + const policy = extension.collisionPolicy || 'error'; + if (!['error', 'skip', 'overwrite'].includes(policy)) { + throw new TypeError(`Unknown collisionPolicy "${policy}".`); + } + + const files = { ...project.files }; + const diagnostics = [...project.diagnostics]; + const extFiles = extension.files || {}; + + // Validate paths AND content up front: an invalid path or a non-string value + // anywhere means the whole extension is rejected, not half-applied. + const { diagnostics: pathDiag } = validatePathMap(extFiles); + const fatal = [...pathDiag.filter((d) => d.severity === 'error')]; + for (const [path, contents] of Object.entries(extFiles)) { + if (typeof contents !== 'string') { + fatal.push({ severity: 'error', code: 'INVALID_FILE_CONTENT', field: path, message: `Contents of "${path}" must be a string.`, source: 'extend' }); + } + } + if (fatal.length) throw new PackkitValidationError('Extension files are not valid; see error.diagnostics.', fatal); + + const prevState = extensionState.get(project) || { files: {}, packageJson: {} }; + const stateFiles = { ...prevState.files }; + + for (const [path, contents] of Object.entries(extFiles)) { + const target = validateRelativePath(path).normalized; + const collides = target in files; + if (collides) { + if (policy === 'error') { + throw new PackkitValidationError(`Extension file "${path}" collides with a generated file.`, [ + { severity: 'error', code: 'EXTENSION_FILE_COLLISION', field: path, message: `"${path}" already exists in the generated project.`, source: 'extend' }, + ]); + } + diagnostics.push({ + severity: 'info', + code: policy === 'skip' ? 'EXTENSION_FILE_SKIPPED' : 'EXTENSION_FILE_OVERWRITTEN', + field: path, + message: policy === 'skip' ? `"${path}" was kept from the generated project.` : `"${path}" was replaced by the extension.`, + source: 'extend', + }); + if (policy === 'skip') continue; + } + files[target] = contents; + // "add" = the host introduced a path Packkit didn't generate; "replace" = + // deliberately overriding generated output. Recorded so a stored definition + // can tell, on replay under a newer Packkit, whether an add now collides + // with a file that version started generating. A stored mode (from replay) + // wins over the freshly-computed one, so the original intent survives a + // load-and-save round-trip. + const mode = storedModes && target in storedModes ? storedModes[target] : collides ? 'replace' : 'add'; + stateFiles[target] = { content: contents, mode }; + } + + let packageJson = prevState.packageJson; + if (extension.packageJson && Object.keys(extension.packageJson).length) { + const current = JSON.parse(files['package.json']); + diagnostics.push(...extensionPackageDiagnostics(current, extension.packageJson)); + const mergedPkg = finalizePackageJson(deepMerge(current, extension.packageJson)); + files['package.json'] = toJson(mergedPkg); + // Keep the raw override for definition export, minus prototype-poisoning keys. + packageJson = deepMerge(packageJson, extension.packageJson); + } + + return finish({ + config: project.config, + files, + summary: { ...project.summary, fileCount: Object.keys(files).length }, + diagnostics, + metadata: { ...project.metadata, ...(extension.metadata ? { extension: extension.metadata } : {}) }, + deploymentContract: project.deploymentContract, + }, { files: stateFiles, packageJson }); +} + +/** + * A serializable definition that reproduces this project later. Contains no + * secrets and no absolute paths — the config, preset, and the extension + * material the host layered on (each file tagged add/replace). + */ +export function exportProjectDefinition(project) { + assertProject(project); + const state = extensionState.get(project) || { files: {}, packageJson: {} }; + return { + schemaVersion: SCHEMA_VERSION, + packkitVersion: project.metadata.packkitVersion, + preset: project.metadata.preset, + config: serializableConfig(project.config), + extensions: { + files: Object.fromEntries(Object.entries(state.files).map(([p, e]) => [p, { content: e.content, mode: e.mode }])), + packageJson: { ...state.packageJson }, + }, + }; +} + +/** Rebuild a project from a stored definition, re-applying its extensions. */ +export function createProjectFromDefinition(definition) { + validateDefinition(definition); + const current = packkitVersion(); + const base = createProject({ preset: definition.preset, config: definition.config }); + + const drift = []; + if (definition.packkitVersion && definition.packkitVersion !== current) { + drift.push({ + severity: 'warning', + code: 'PACKKIT_VERSION_DRIFT', + field: 'packkitVersion', + message: `Definition was created with Packkit ${definition.packkitVersion}; this is ${current}. Output may differ.`, + source: 'definition', + previousValue: definition.packkitVersion, + resolvedValue: current, + }); + } + + const ext = definition.extensions || {}; + const extFiles = ext.files || {}; + const plainFiles = {}; + const storedModes = {}; + for (const [path, entry] of Object.entries(extFiles)) { + const { content, mode } = normalizeDefinitionFile(entry); + plainFiles[path] = content; + storedModes[validateRelativePath(path).normalized] = mode; + // A file the host originally *added* now collides with something this + // Packkit version generates: surface it loudly instead of silently taking + // the stored copy. The definition still reproduces (the host file wins, as + // it did originally), but the drift is now visible for reconciliation. + if (mode === 'add' && base.files[path] !== undefined) { + drift.push({ + severity: 'error', + code: 'EXTENSION_ADD_COLLIDES_WITH_NEW_BASE', + field: path, + message: `"${path}" was originally added by the host, but Packkit ${current} now generates it too. The stored copy was used; review the difference.`, + source: 'definition', + }); + } + } + + const hasExt = Object.keys(plainFiles).length || Object.keys(ext.packageJson || {}).length; + // Carry the stored modes so re-exporting the replayed project preserves the + // original add/replace intent rather than re-deriving it against this base. + const result = hasExt + ? extendProject(base, { files: plainFiles, packageJson: ext.packageJson || {}, collisionPolicy: 'overwrite' }, { fileModes: storedModes }) + : base; + result.diagnostics.push(...drift); + return result; +} + +/** + * A stable digest of the project's config and file contents. Two projects with + * the same Packkit version, config, and extensions produce the same digest; + * nondeterministic metadata (timestamps) is deliberately excluded. + */ +export function calculateProjectDigest(project) { + assertProject(project); + const h = createHash('sha256'); + h.update('config\0'); + h.update(JSON.stringify(serializableConfig(project.config))); + for (const path of Object.keys(project.files).sort()) { + h.update(`\0file\0${path}\0`); + h.update(project.files[path]); + } + return h.digest('hex'); +} + +// ---- internals ------------------------------------------------------------- + +function finish(project, state) { + extensionState.set(project, state); + return project; +} + +function assertProject(project) { + if (!project || typeof project !== 'object' || !project.files || !project.config) { + throw new TypeError('Expected a GeneratedProject from createProject().'); + } +} + +function serializableConfig(config) { + const out = {}; + for (const key of Object.keys(OPTIONS)) { + if (config[key] !== undefined) out[key] = config[key]; + } + if (config.preset) out.preset = config.preset; + return sortObject(out); +} + +function sortObject(obj) { + return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]])); +} + +function validateInput(config) { + const errors = []; + if (config.name != null && (typeof config.name !== 'string' || config.name.trim() === '')) { + errors.push({ severity: 'error', code: 'INVALID_NAME', field: 'name', message: 'name must be a non-empty string.', source: 'validate' }); + } + for (const [key, value] of Object.entries(config)) { + const opt = OPTIONS[key]; + if (!opt) continue; + if (opt.type === 'boolean' && typeof value !== 'boolean') { + errors.push(valueError(key, value, 'must be true or false')); + } else if (opt.choices) { + const allowed = opt.choices.map((c) => c.value); + const values = opt.type === 'multiselect' ? (Array.isArray(value) ? value : [value]) : [value]; + for (const v of values) { + if (!allowed.includes(v)) errors.push(valueError(key, v, `must be one of: ${allowed.join(', ')}`)); + } + } + } + return errors; +} + +function valueError(field, value, detail) { + return { severity: 'error', code: 'VALUE_NOT_ALLOWED', field, previousValue: value, message: `${field} ${detail}.`, source: 'validate' }; +} + +function unknownOptionDiagnostics(config) { + const out = []; + for (const key of Object.keys(config)) { + if (OPTIONS[key] || KNOWN_EXTRA_KEYS.has(key)) continue; + out.push({ severity: 'warning', code: 'UNKNOWN_OPTION', field: key, message: `"${key}" is not a Packkit option and was ignored.`, source: 'validate' }); + } + return out; +} + +// Report host package overrides that change an existing generated value, so the +// host still wins but the change is visible (it can invalidate the deployment +// contract — e.g. redefining scripts.build). +function extensionPackageDiagnostics(current, override) { + const out = []; + for (const [topKey, value] of Object.entries(override)) { + if (DEP_MAPS.has(topKey) && current[topKey]) { + for (const [dep, version] of Object.entries(value || {})) { + const prev = current[topKey][dep]; + if (prev !== undefined && prev !== version) { + out.push({ severity: 'warning', code: 'EXTENSION_DEPENDENCY_VERSION_OVERRIDE', field: `${topKey}.${dep}`, message: `The extension changed ${topKey}.${dep} from ${prev} to ${version}.`, source: 'extend', previousValue: prev, resolvedValue: version }); + } + } + } else if (PROTECTED_PKG_FIELDS.has(topKey) && current[topKey] !== undefined) { + if (value && typeof value === 'object' && !Array.isArray(value) && typeof current[topKey] === 'object') { + for (const [k, v] of Object.entries(value)) { + const prev = current[topKey][k]; + if (prev !== undefined && JSON.stringify(prev) !== JSON.stringify(v)) { + out.push({ severity: 'warning', code: 'EXTENSION_PACKAGE_FIELD_OVERRIDE', field: `${topKey}.${k}`, message: `The extension changed ${topKey}.${k}.`, source: 'extend', previousValue: prev, resolvedValue: v }); + } + } + } else if (JSON.stringify(current[topKey]) !== JSON.stringify(value)) { + out.push({ severity: 'warning', code: 'EXTENSION_PACKAGE_FIELD_OVERRIDE', field: topKey, message: `The extension changed ${topKey}.`, source: 'extend', previousValue: current[topKey], resolvedValue: value }); + } + } + } + return out; +} + +function normalizeDefinitionFile(entry) { + // v2 stores { content, mode }; tolerate a bare string (mode unknown → treat as + // a deliberate replace so it never falsely reports an add-collision). + if (typeof entry === 'string') return { content: entry, mode: 'replace' }; + return { content: entry.content, mode: entry.mode === 'add' ? 'add' : 'replace' }; +} + +// Definitions can arrive from untrusted stores, so validate structure, guard +// against prototype-pollution keys, cap resource use, and re-check every path +// before any of it can reach generation or disk. +function validateDefinition(definition) { + const errs = []; + const fail = (code, message, field) => errs.push({ severity: 'error', code, message, field, source: 'definition' }); + + if (!isPlainObject(definition)) throw new PackkitValidationError('A definition object is required.', [{ severity: 'error', code: 'INVALID_DEFINITION', message: 'Definition must be a plain object.', source: 'definition' }]); + if (definition.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_VERSION_MISMATCH', `Definition schemaVersion ${definition.schemaVersion} is not supported (expected ${SCHEMA_VERSION}).`, 'schemaVersion'); + if (definition.config !== undefined && !isPlainObject(definition.config)) fail('INVALID_DEFINITION', 'config must be a plain object.', 'config'); + if (definition.config) assertNoUnsafeKeys(definition.config, 'config', fail); + + const ext = definition.extensions; + if (ext !== undefined) { + if (!isPlainObject(ext)) fail('INVALID_DEFINITION', 'extensions must be a plain object.', 'extensions'); + else { + const files = ext.files || {}; + if (!isPlainObject(files)) fail('INVALID_DEFINITION', 'extensions.files must be an object.', 'extensions.files'); + else { + const paths = Object.keys(files); + if (paths.length > MAX_DEFINITION_FILES) fail('DEFINITION_TOO_LARGE', `Definition has ${paths.length} files (max ${MAX_DEFINITION_FILES}).`, 'extensions.files'); + let total = 0; + for (const [p, entry] of Object.entries(files)) { + const content = typeof entry === 'string' ? entry : entry && entry.content; + if (typeof content !== 'string') fail('INVALID_FILE_CONTENT', `extensions.files["${p}"] must have string content.`, p); + else total += Buffer.byteLength(content, 'utf8'); // count UTF-8 bytes, matching the "bytes" limit + } + if (total > MAX_DEFINITION_BYTES) fail('DEFINITION_TOO_LARGE', `Definition content is ${total} bytes (max ${MAX_DEFINITION_BYTES}).`, 'extensions.files'); + for (const d of validatePathMap(Object.fromEntries(paths.map((p) => [p, '']))).diagnostics) errs.push({ ...d, source: 'definition' }); + } + if (ext.packageJson !== undefined && !isPlainObject(ext.packageJson)) fail('INVALID_DEFINITION', 'extensions.packageJson must be an object.', 'extensions.packageJson'); + if (isPlainObject(ext.packageJson)) assertNoUnsafeKeys(ext.packageJson, 'extensions.packageJson', fail); + } + } + + if (errs.length) throw new PackkitValidationError('The project definition is not valid; see error.diagnostics.', errs); +} + +function assertNoUnsafeKeys(obj, path, fail) { + for (const key of Object.keys(obj)) { + if (UNSAFE_KEYS.has(key)) fail('UNSAFE_KEY', `"${path}.${key}" is not an allowed key.`, `${path}.${key}`); + else if (isPlainObject(obj[key])) assertNoUnsafeKeys(obj[key], `${path}.${key}`, fail); + } +} + +function isPlainObject(v) { + return v != null && typeof v === 'object' && !Array.isArray(v); +} diff --git a/src/embedded/paths.js b/src/embedded/paths.js new file mode 100644 index 0000000..f76e4ee --- /dev/null +++ b/src/embedded/paths.js @@ -0,0 +1,88 @@ +// Path validation for generated and extension-supplied files. +// +// Every path that could reach the filesystem passes through here — once when a +// project is assembled, and again at the writer boundary. A path that escapes +// the destination, is absolute, or is otherwise unsafe is rejected rather than +// written, so a host application embedding Packkit can accept file maps from +// less-trusted sources (extensions, stored definitions) without opening a +// path-traversal hole. + +import { posix } from 'node:path'; + +// Windows reserves these device names regardless of extension (CON.txt is still +// CON). Rejecting them keeps generated projects writable on Windows. +const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i; + +/** + * Validate a repo-relative file path. Returns { ok: true, normalized } or + * { ok: false, code, message }. Pure — never touches the filesystem. + */ +export function validateRelativePath(path) { + if (typeof path !== 'string' || path.length === 0) { + return fail('EMPTY_PATH', 'A file path must be a non-empty string.'); + } + if (path.includes('\0')) { + return fail('NULL_BYTE', `Path contains a null byte: ${JSON.stringify(path)}`); + } + // Treat backslashes as separators so a Windows-style path can't smuggle a + // segment past the checks below on a POSIX host. + const unified = path.replace(/\\/g, '/'); + if (/^[a-zA-Z]:/.test(unified) || unified.startsWith('/')) { + return fail('ABSOLUTE_PATH', `Path must be relative, not absolute: ${path}`); + } + + const normalized = posix.normalize(unified); + if (normalized === '.' || normalized === '' || normalized.endsWith('/')) { + return fail('NOT_A_FILE', `Path does not name a file: ${path}`); + } + // normalize() collapses interior `..`; anything that still starts with `..` + // (or is exactly `..`) escapes the destination. + if (normalized === '..' || normalized.startsWith('../')) { + return fail('PATH_ESCAPE', `Path escapes the destination directory: ${path}`); + } + for (const segment of normalized.split('/')) { + if (WINDOWS_RESERVED.test(segment)) { + return fail('WINDOWS_RESERVED', `Path uses a Windows-reserved name: ${path}`); + } + } + return { ok: true, normalized }; +} + +/** + * Validate a whole file map. Returns { paths, diagnostics } where `paths` maps + * each original key to its normalized form, and diagnostics carries one error + * per invalid path plus one per case-insensitive collision (two distinct paths + * that would be the same file on a case-insensitive filesystem). + */ +export function validatePathMap(files) { + const paths = {}; + const diagnostics = []; + const lowered = new Map(); // lowercased normalized path -> first original key + + for (const original of Object.keys(files)) { + const res = validateRelativePath(original); + if (!res.ok) { + diagnostics.push({ severity: 'error', code: res.code, message: res.message, source: 'path', field: original }); + continue; + } + paths[original] = res.normalized; + + const key = res.normalized.toLowerCase(); + if (lowered.has(key) && lowered.get(key) !== res.normalized) { + diagnostics.push({ + severity: 'error', + code: 'CASE_INSENSITIVE_COLLISION', + message: `"${original}" and "${lowered.get(key)}" are the same file on a case-insensitive filesystem.`, + source: 'path', + field: original, + }); + } else { + lowered.set(key, res.normalized); + } + } + return { paths, diagnostics }; +} + +function fail(code, message) { + return { ok: false, code, message }; +} diff --git a/src/embedded/pkg-merge.js b/src/embedded/pkg-merge.js new file mode 100644 index 0000000..59744c0 --- /dev/null +++ b/src/embedded/pkg-merge.js @@ -0,0 +1,89 @@ +// Provenance-tracked package.json analysis. +// +// The core already merges feature fragments (deepMerge, last writer wins) to +// produce the actual package.json. This module does not replace that — it runs +// alongside it to answer "who set this field, and did two contributors +// disagree?", so the embedded API can report conflicts instead of letting a +// silent overwrite hide a bug. + +// Fields where two contributors setting the *same leaf* to *different values* +// is a real conflict a host should hear about. Dependency maps are handled +// separately (version-aware, per section); everything else deep-merges. +const PROTECTED = new Set(['scripts', 'exports', 'bin', 'main', 'module', 'types', 'files', 'engines', 'packageManager']); +const DEP_MAPS = new Set(['dependencies', 'devDependencies', 'peerDependencies']); + +/** + * Analyze an ordered list of { source, pkg } fragments. + * Returns { diagnostics } describing every protected-field leaf that two + * sources set to conflicting values, and every dependency pinned to two + * different versions *within the same section*. Does not produce package.json. + */ +export function analyzePkgFragments(fragments) { + const diagnostics = []; + const leaves = new Map(); // rendered leaf path -> { value, source } + // Keyed by section + name, so `dependencies.react` and `peerDependencies.react` + // are distinct — differing versions across those sections is normal, not a bug. + const deps = new Map(); + + for (const { source, pkg } of fragments) { + for (const [topKey, value] of Object.entries(pkg)) { + if (DEP_MAPS.has(topKey)) { + for (const [dep, version] of Object.entries(value || {})) { + const key = `${topKey}:${dep}`; + const prev = deps.get(key); + if (prev && prev.version !== version) { + diagnostics.push({ + severity: 'warning', + code: 'DEPENDENCY_VERSION_CONFLICT', + field: `${topKey}.${dep}`, + message: `"${dep}" in ${topKey} is requested at ${prev.version} (by ${prev.source}) and ${version} (by ${source}).`, + source: 'package-merge', + previousValue: prev.version, + resolvedValue: version, + }); + } + deps.set(key, { version, source }); + } + continue; + } + if (PROTECTED.has(topKey)) { + collectLeaves([topKey], value, (segments, leafValue) => { + const rendered = renderPath(segments); + const prev = leaves.get(rendered); + if (prev && JSON.stringify(prev.value) !== JSON.stringify(leafValue)) { + diagnostics.push({ + severity: 'warning', + code: 'PACKAGE_FIELD_CONFLICT', + field: rendered, + message: `"${rendered}" is set to different values by ${prev.source} and ${source}; the later one wins.`, + source: 'package-merge', + previousValue: prev.value, + resolvedValue: leafValue, + }); + } + leaves.set(rendered, { value: leafValue, source }); + }); + } + } + } + return { diagnostics }; +} + +// Recursively flatten a protected field to its leaf values, carrying the key +// path as an array so a key that itself contains a dot (e.g. exports './sub.js') +// isn't mistaken for two segments. +function collectLeaves(segments, value, emit) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [k, v] of Object.entries(value)) collectLeaves([...segments, k], v, emit); + } else { + emit(segments, value); + } +} + +// Render a segment path for human-facing diagnostics. Dotted segments get +// bracket notation so the field reads unambiguously. +function renderPath(segments) { + return segments + .map((s, i) => (i > 0 && s.includes('.') ? `['${s}']` : i > 0 ? `.${s}` : s)) + .join(''); +} diff --git a/src/embedded/writer.js b/src/embedded/writer.js new file mode 100644 index 0000000..c5c09ad --- /dev/null +++ b/src/embedded/writer.js @@ -0,0 +1,151 @@ +// Controlled filesystem output for a GeneratedProject. +// +// This is the only place the embedded API touches disk, and it does nothing +// else: no install, no git, no lifecycle scripts, no command execution. Every +// path is validated again here — not just when the project was built — and the +// real on-disk path is checked for symlinks, so a project that reached this +// boundary from an untrusted source still can't escape the destination. + +import { mkdir, writeFile, stat, lstat } from 'node:fs/promises'; +import { dirname, join, resolve, sep } from 'node:path'; +import { validateRelativePath } from './paths.js'; + +export class PackkitWriteError extends Error { + constructor(message, { code, path, destination, cause } = {}) { + super(message); + this.name = 'PackkitWriteError'; + this.code = code; + if (path !== undefined) this.path = path; + if (destination !== undefined) this.destination = destination; + if (cause !== undefined) this.cause = cause; + } +} + +/** + * Write a project's files under `destination`. Returns a WriteResult; never + * installs, inits git, or runs anything. Validates and preflights everything + * before the first write, so a bad file map or a policy collision fails cleanly + * rather than leaving a half-written project. + * + * @param {{ project: object, destination: string, collisionPolicy?: 'error'|'skip'|'overwrite' }} input + */ +export async function writeGeneratedProject(input) { + const { project, destination, collisionPolicy = 'error' } = input || {}; + if (!project || typeof project !== 'object' || !project.files) { + throw new TypeError('writeGeneratedProject needs a project with a files map.'); + } + if (typeof destination !== 'string' || destination.length === 0) { + throw new TypeError('A destination path is required.'); + } + if (!['error', 'skip', 'overwrite'].includes(collisionPolicy)) { + throw new TypeError(`Unknown collisionPolicy "${collisionPolicy}".`); + } + + const root = resolve(destination); + const prefix = root.endsWith(sep) ? root : root + sep; + + // A symlinked destination could redirect the whole write outside where the + // caller thinks it's going. Reject it — a host that truly wants this can + // resolve the real path itself before calling. + await assertNotSymlink(root, false, root, root); + + // 1) Lexical validation: any traversal/absolute path means we write nothing. + const planned = []; + for (const [path, contents] of Object.entries(project.files)) { + const res = validateRelativePath(path); + if (!res.ok) { + throw new PackkitWriteError(`Refusing to write invalid path "${path}": ${res.message}`, { code: res.code, path, destination: root }); + } + const target = join(root, res.normalized); + if (target !== root && !target.startsWith(prefix)) { + throw new PackkitWriteError(`Refusing to write outside the destination: "${path}"`, { code: 'PATH_ESCAPE', path, destination: root }); + } + planned.push({ path: res.normalized, target, contents }); + } + + // 2) Symlink + collision preflight against the real filesystem, before any + // write. Under the 'error' policy, one existing target aborts the whole + // operation with every collision listed — no partial output. + const collisions = []; + for (const { path, target } of planned) { + await assertNoSymlinkComponents(root, target, path); + if (await exists(target)) { + if (collisionPolicy === 'error') collisions.push(path); + } + } + if (collisions.length) { + throw new PackkitWriteError( + `Refusing to overwrite existing file(s): ${collisions.join(', ')}`, + { code: 'FILE_EXISTS', destination: root }, + ); + } + + // 3) Write. + const writtenFiles = []; + const skippedFiles = []; + const diagnostics = []; + for (const { path, target, contents } of planned) { + try { + if ((collisionPolicy === 'skip') && (await exists(target))) { + skippedFiles.push(path); + diagnostics.push({ severity: 'info', code: 'FILE_SKIPPED', field: path, message: `"${path}" already existed and was left in place.`, source: 'writer' }); + continue; + } + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents); + writtenFiles.push(path); + } catch (err) { + // A real filesystem failure on one file: record it, keep going, so the + // caller sees exactly what landed and what didn't. + diagnostics.push({ severity: 'error', code: 'WRITE_FAILED', field: path, message: `Could not write "${path}": ${err.message}`, source: 'writer' }); + } + } + + return { destination: root, writtenFiles, skippedFiles, diagnostics }; +} + +// Reject any existing symlink among the path components from root to target, +// including the target itself — a lexical containment check can't catch +// `dest/link -> /elsewhere`, but following real inodes can. Writing through an +// existing symlink (even the final file, under 'overwrite') would escape. +async function assertNoSymlinkComponents(root, target, path) { + const rel = target.slice(root.length + 1); + if (!rel) return; + const segments = rel.split(sep); + let current = root; + for (let i = 0; i < segments.length; i++) { + current = join(current, segments[i]); + const isFinal = i === segments.length - 1; + await assertNotSymlink(current, isFinal, path, root); + } +} + +async function assertNotSymlink(component, isFinal, path, destination) { + try { + const info = await lstat(component); + if (info.isSymbolicLink()) { + throw new PackkitWriteError(`Refusing to write through a symbolic link: "${component}"`, { code: 'SYMLINK_PATH', path, destination }); + } + // Intermediate components must be directories; the final one may already + // exist as a regular file (collision handling decides what to do with it). + if (!info.isDirectory() && !isFinal) { + throw new PackkitWriteError(`A parent path component is not a directory: "${component}"`, { code: 'PARENT_NOT_DIRECTORY', path, destination }); + } + } catch (err) { + if (err instanceof PackkitWriteError) throw err; + if (err.code === 'ENOENT') return; // doesn't exist yet; nothing to follow + throw new PackkitWriteError(`Could not inspect "${component}": ${err.message}`, { code: 'STAT_FAILED', path, destination, cause: err }); + } +} + +// Only ENOENT means "not there" — permission and I/O errors are real problems +// and must surface, not be silently read as a missing file. +async function exists(p) { + try { + await stat(p); + return true; + } catch (err) { + if (err.code === 'ENOENT') return false; + throw new PackkitWriteError(`Could not stat "${p}": ${err.message}`, { code: 'STAT_FAILED', cause: err }); + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..75203da --- /dev/null +++ b/src/index.js @@ -0,0 +1,12 @@ +// Package root. Re-exports the pure generation core plus the Node embedded API, +// so `import { generate, createProject, writeGeneratedProject } from 'create-packkit'` +// works for host applications. +// +// The browser configurator does NOT import this file — it bundles +// src/core/index.js directly (see build:web) — so pulling the Node-only +// embedded modules in here doesn't affect browser compatibility of the core. +// Consumers who want the core in isolation can import 'create-packkit/core'. + +export * from './core/index.js'; +export * from './embedded/index.js'; +export { writeGeneratedProject, PackkitWriteError } from './embedded/writer.js'; diff --git a/test/embedded.test.js b/test/embedded.test.js new file mode 100644 index 0000000..8c9b842 --- /dev/null +++ b/test/embedded.test.js @@ -0,0 +1,371 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createProject, + extendProject, + exportProjectDefinition, + createProjectFromDefinition, + calculateProjectDigest, + deriveDeploymentContract, + PackkitValidationError, + SCHEMA_VERSION, +} from '../src/embedded/index.js'; +import { writeGeneratedProject, PackkitWriteError } from '../src/embedded/writer.js'; +import { validateRelativePath } from '../src/embedded/paths.js'; + +const tmp = () => mkdtemp(join(tmpdir(), 'pk-embed-')); + +// ---- createProject --------------------------------------------------------- + +test('createProject: generates in memory with no side effects', () => { + const p = createProject({ preset: 'react-app', name: 'app' }); + assert.ok(p.files['package.json']); + assert.equal(p.summary.fileCount, Object.keys(p.files).length); + assert.equal(p.metadata.preset, 'react-app'); + assert.equal(p.metadata.schemaVersion, SCHEMA_VERSION); + assert.ok(p.metadata.packkitVersion); + // deterministic: no timestamp baked in unless asked + assert.equal(p.metadata.generatedAt, undefined); +}); + +test('createProject: overrides apply after the preset', () => { + const p = createProject({ preset: 'ts-lib', name: 'x', overrides: { packageManager: 'pnpm' } }); + assert.equal(p.config.packageManager, 'pnpm'); +}); + +test('createProject: reports normalization changes instead of applying them silently', () => { + const p = createProject({ preset: 'node-service', name: 'svc', overrides: { storybook: true } }); + const d = p.diagnostics.find((x) => x.code === 'STORYBOOK_REQUIRES_COMPONENT_LIBRARY'); + assert.ok(d, 'storybook coercion reported'); + assert.equal(d.previousValue, true); + assert.equal(d.resolvedValue, false); + assert.equal(d.severity, 'warning'); +}); + +test('createProject: unknown options are a warning, not fatal', () => { + const p = createProject({ name: 'x', config: { madeUpOption: 1 } }); + assert.ok(p.diagnostics.some((d) => d.code === 'UNKNOWN_OPTION' && d.field === 'madeUpOption')); +}); + +test('createProject: an out-of-range value is fatal', () => { + assert.throws( + () => createProject({ name: 'x', config: { language: 'cobol' } }), + (e) => e instanceof PackkitValidationError && e.diagnostics[0].code === 'VALUE_NOT_ALLOWED', + ); +}); + +test('createProject: an unknown preset is fatal', () => { + assert.throws( + () => createProject({ preset: 'does-not-exist', name: 'x' }), + (e) => e instanceof PackkitValidationError && e.diagnostics[0].code === 'UNKNOWN_PRESET', + ); +}); + +// ---- extendProject --------------------------------------------------------- + +test('extendProject: adds files and never mutates the original', () => { + const base = createProject({ preset: 'ts-lib', name: 'lib' }); + const beforeCount = Object.keys(base.files).length; + const ext = extendProject(base, { files: { '.github/workflows/deploy.yml': 'name: deploy\n' } }); + assert.equal(Object.keys(base.files).length, beforeCount, 'base unchanged'); + assert.equal(ext.files['.github/workflows/deploy.yml'], 'name: deploy\n'); + assert.equal(ext.summary.fileCount, beforeCount + 1); +}); + +test('extendProject: default collision policy is error', () => { + const base = createProject({ preset: 'ts-lib', name: 'lib' }); + assert.throws( + () => extendProject(base, { files: { 'package.json': '{}' } }), + (e) => e instanceof PackkitValidationError && e.diagnostics[0].code === 'EXTENSION_FILE_COLLISION', + ); +}); + +test('extendProject: skip keeps the generated file; overwrite replaces it', () => { + const base = createProject({ preset: 'ts-lib', name: 'lib' }); + const original = base.files['package.json']; + const skipped = extendProject(base, { files: { 'package.json': 'REPLACED' }, collisionPolicy: 'skip' }); + assert.equal(skipped.files['package.json'], original); + const overwritten = extendProject(base, { files: { 'package.json': 'REPLACED' }, collisionPolicy: 'overwrite' }); + assert.equal(overwritten.files['package.json'], 'REPLACED'); +}); + +test('extendProject: rejects a traversal path in an extension', () => { + const base = createProject({ preset: 'ts-lib', name: 'lib' }); + assert.throws( + () => extendProject(base, { files: { '../escape.txt': 'x' } }), + (e) => e instanceof PackkitValidationError && e.diagnostics[0].code === 'PATH_ESCAPE', + ); +}); + +test('extendProject: package.json overrides deep-merge, host wins', () => { + const base = createProject({ preset: 'node-service', name: 'svc' }); + const ext = extendProject(base, { packageJson: { scripts: { deploy: 'do-it' } } }); + const pkg = JSON.parse(ext.files['package.json']); + assert.equal(pkg.scripts.deploy, 'do-it'); + assert.ok(pkg.scripts.start, 'existing scripts preserved'); +}); + +// ---- path validation ------------------------------------------------------- + +test('validateRelativePath: rejects the classic escapes', () => { + for (const bad of ['../outside.txt', '/etc/passwd', 'C:\\outside.txt', 'src/../../outside.txt', '', 'a\0b']) { + assert.equal(validateRelativePath(bad).ok, false, `should reject ${JSON.stringify(bad)}`); + } +}); + +test('validateRelativePath: accepts normal nested paths', () => { + const r = validateRelativePath('src/a/b/c.ts'); + assert.equal(r.ok, true); + assert.equal(r.normalized, 'src/a/b/c.ts'); +}); + +// ---- definition + digest --------------------------------------------------- + +test('exportProjectDefinition + createProjectFromDefinition reproduce the same digest', () => { + const project = createProject({ preset: 'react-app', name: 'example-app' }); + const extended = extendProject(project, { files: { '.github/workflows/deploy.yml': 'name: deploy\n' } }); + const definition = exportProjectDefinition(extended); + assert.equal(definition.schemaVersion, SCHEMA_VERSION); + const recreated = createProjectFromDefinition(definition); + assert.equal(calculateProjectDigest(extended), calculateProjectDigest(recreated)); +}); + +test('definition carries no absolute paths or secrets, only config + extensions', () => { + const p = extendProject(createProject({ preset: 'ts-lib', name: 'lib' }), { files: { 'x.txt': 'hi' } }); + const def = exportProjectDefinition(p); + const json = JSON.stringify(def); + assert.doesNotMatch(json, /\/(Users|home|tmp|var)\//, 'no machine paths'); + assert.deepEqual(def.extensions.files['x.txt'], { content: 'hi', mode: 'add' }); +}); + +test('a definition from an incompatible schema version is rejected', () => { + assert.throws( + () => createProjectFromDefinition({ schemaVersion: 999, packkitVersion: '9.9.9', config: {} }), + (e) => e instanceof PackkitValidationError && e.diagnostics[0].code === 'SCHEMA_VERSION_MISMATCH', + ); +}); + +test('calculateProjectDigest is stable across repeated generation', () => { + const a = createProject({ preset: 'node-service', name: 'svc' }); + const b = createProject({ preset: 'node-service', name: 'svc' }); + assert.equal(calculateProjectDigest(a), calculateProjectDigest(b)); +}); + +// ---- deployment contract --------------------------------------------------- + +test('deriveDeploymentContract: shape per target, provider-neutral', () => { + const svc = createProject({ preset: 'node-service', name: 'svc' }).deploymentContract; + assert.equal(svc.type, 'node-service'); + assert.equal(svc.port, 3000); + assert.equal(svc.healthCheckPath, '/health'); + + const app = createProject({ preset: 'react-app', name: 'app' }).deploymentContract; + assert.deepEqual(app, { type: 'static', buildCommand: 'npm run build', outputDirectory: 'dist' }); + + const lib = createProject({ preset: 'ts-lib', name: 'lib' }).deploymentContract; + assert.equal(lib.type, 'library'); + + // No provider-specific fields leak in. + const json = JSON.stringify([svc, app, lib]); + assert.doesNotMatch(json, /vercel|netlify|aws|cloudflare|github/i); +}); + +// ---- writer (filesystem) --------------------------------------------------- + +test('writeGeneratedProject: writes to an empty dir, nested paths included', async () => { + const dir = await tmp(); + const p = createProject({ preset: 'ts-lib', name: 'lib' }); + const res = await writeGeneratedProject({ project: p, destination: dir }); + assert.equal(res.writtenFiles.length, Object.keys(p.files).length); + assert.ok((await stat(join(dir, 'package.json'))).isFile()); + assert.equal(await readFile(join(dir, 'package.json'), 'utf8'), p.files['package.json']); +}); + +test('writeGeneratedProject: refuses a traversal path at the boundary, writes nothing', async () => { + const dir = await tmp(); + await assert.rejects( + () => writeGeneratedProject({ project: { config: {}, files: { '../evil.txt': 'x', 'ok.txt': 'y' } }, destination: dir }), + (e) => e instanceof PackkitWriteError && e.code === 'PATH_ESCAPE', + ); + await assert.rejects(() => stat(join(dir, 'ok.txt')), 'nothing was written'); +}); + +test('writeGeneratedProject: collision policies', async () => { + const dir = await tmp(); + await writeFile(join(dir, 'keep.txt'), 'original'); + const project = { config: {}, files: { 'keep.txt': 'new', 'fresh.txt': 'new' } }; + + await assert.rejects( + () => writeGeneratedProject({ project, destination: dir, collisionPolicy: 'error' }), + (e) => e instanceof PackkitWriteError && e.code === 'FILE_EXISTS', + ); + + const skip = await writeGeneratedProject({ project, destination: dir, collisionPolicy: 'skip' }); + assert.deepEqual(skip.skippedFiles, ['keep.txt']); + assert.equal(await readFile(join(dir, 'keep.txt'), 'utf8'), 'original'); +}); + +test('writeGeneratedProject: filenames with spaces and Unicode', async () => { + const dir = await tmp(); + const project = { config: {}, files: { 'a folder/rΓⁿ file 日本.txt': 'ok' } }; + const res = await writeGeneratedProject({ project, destination: dir }); + assert.equal(res.writtenFiles.length, 1); + assert.equal(await readFile(join(dir, 'a folder/rΓⁿ file 日本.txt'), 'utf8'), 'ok'); +}); + +test('writeGeneratedProject: does not install, init git, or run commands', async () => { + const dir = await tmp(); + const p = createProject({ preset: 'node-service', name: 'svc' }); + await writeGeneratedProject({ project: p, destination: dir }); + await assert.rejects(() => stat(join(dir, 'node_modules')), 'no install'); + await assert.rejects(() => stat(join(dir, '.git')), 'no git init'); +}); + +// ---- review fixes: security & correctness ---------------------------------- + +test('writer: refuses to write through a symlinked directory component', async () => { + const { symlink, mkdir: mkdirp } = await import('node:fs/promises'); + const dir = await tmp(); + const outside = await tmp(); + await mkdirp(join(outside, 'real')); + await symlink(join(outside, 'real'), join(dir, 'link')); + await assert.rejects( + () => writeGeneratedProject({ project: { config: {}, files: { 'link/escaped.txt': 'x' } }, destination: dir }), + (e) => e instanceof PackkitWriteError && e.code === 'SYMLINK_PATH', + ); +}); + +test('writer: rejects a symlinked destination itself', async () => { + const { symlink } = await import('node:fs/promises'); + const dir = await tmp(); + const outside = await tmp(); + const linkDest = join(dir, 'dest'); + await symlink(outside, linkDest); + await assert.rejects( + () => writeGeneratedProject({ project: { config: {}, files: { 'a.txt': 'x' } }, destination: linkDest }), + (e) => e instanceof PackkitWriteError && e.code === 'SYMLINK_PATH', + ); +}); + +test('writer: error policy preflights all collisions, writes nothing', async () => { + const dir = await tmp(); + await writeFile(join(dir, 'a.txt'), 'existing'); + const project = { config: {}, files: { 'a.txt': 'new', 'b.txt': 'new' } }; + await assert.rejects( + () => writeGeneratedProject({ project, destination: dir, collisionPolicy: 'error' }), + (e) => e instanceof PackkitWriteError && e.code === 'FILE_EXISTS' && e.message.includes('a.txt'), + ); + // b.txt must NOT have been written — the collision aborts before any write. + await assert.rejects(() => stat(join(dir, 'b.txt'))); +}); + +test('writer errors carry structured properties', async () => { + const dir = await tmp(); + const err = await writeGeneratedProject({ project: { config: {}, files: { '../x': 'y' } }, destination: dir }).catch((e) => e); + assert.equal(err.code, 'PATH_ESCAPE'); + assert.equal(err.path, '../x'); + assert.equal(err.destination, (await import('node:path')).resolve(dir)); +}); + +test('definition replay flags an add that a newer base now generates', () => { + // Simulate: the host added a file that (pretend) Packkit now generates too. + const base = createProject({ preset: 'ts-lib', name: 'lib' }); + const generatedPath = Object.keys(base.files)[0]; // any real generated path + const definition = { + schemaVersion: SCHEMA_VERSION, + packkitVersion: '0.0.1', + preset: 'ts-lib', + config: { name: 'lib', preset: 'ts-lib' }, + extensions: { files: { [generatedPath]: { content: 'host-owned', mode: 'add' } }, packageJson: {} }, + }; + const result = createProjectFromDefinition(definition); + const drift = result.diagnostics.find((d) => d.code === 'EXTENSION_ADD_COLLIDES_WITH_NEW_BASE'); + assert.ok(drift, 'collision surfaced'); + assert.equal(drift.severity, 'error'); + assert.equal(result.files[generatedPath], 'host-owned', 'stored copy still reproduced'); +}); + +test('extendProject: host package overrides are reported', () => { + const base = createProject({ preset: 'node-service', name: 'svc' }); + const ext = extendProject(base, { packageJson: { scripts: { start: 'my-custom-start' } } }); + const d = ext.diagnostics.find((x) => x.code === 'EXTENSION_PACKAGE_FIELD_OVERRIDE' && x.field === 'scripts.start'); + assert.ok(d, 'override reported'); + assert.equal(d.resolvedValue, 'my-custom-start'); +}); + +test('extendProject: non-string file content is rejected', () => { + const base = createProject({ preset: 'ts-lib', name: 'lib' }); + assert.throws( + () => extendProject(base, { files: { 'x.txt': { not: 'a string' } } }), + (e) => e instanceof PackkitValidationError && e.diagnostics[0].code === 'INVALID_FILE_CONTENT', + ); +}); + +test('dependency conflicts are keyed per section, not across them', async () => { + const { analyzePkgFragments } = await import('../src/embedded/pkg-merge.js'); + // Different sections with different version ranges is NOT a conflict. + const cross = analyzePkgFragments([ + { source: 'a', pkg: { dependencies: { react: '^19' } } }, + { source: 'b', pkg: { peerDependencies: { react: '>=18' } } }, + ]).diagnostics; + assert.equal(cross.filter((d) => d.code === 'DEPENDENCY_VERSION_CONFLICT').length, 0); + // Two disagreeing versions in the SAME section IS a conflict. + const same = analyzePkgFragments([ + { source: 'a', pkg: { dependencies: { react: '^18' } } }, + { source: 'b', pkg: { dependencies: { react: '^19' } } }, + ]).diagnostics; + assert.equal(same.filter((d) => d.code === 'DEPENDENCY_VERSION_CONFLICT').length, 1); +}); + +test('deepMerge cannot pollute the prototype via host extension keys', () => { + const base = createProject({ preset: 'ts-lib', name: 'lib' }); + // __proto__ as an own key (JSON.parse) must not leak onto Object.prototype. + extendProject(base, { packageJson: JSON.parse('{"__proto__":{"polluted":true},"scripts":{"x":"1"}}') }); + assert.equal({}.polluted, undefined, 'Object.prototype untouched'); +}); + +test('createProjectFromDefinition: rejects unsafe keys and oversized definitions', () => { + assert.throws( + () => createProjectFromDefinition({ schemaVersion: SCHEMA_VERSION, packkitVersion: '1.0.0', config: JSON.parse('{"__proto__":{"x":1}}') }), + (e) => e instanceof PackkitValidationError && e.diagnostics.some((d) => d.code === 'UNSAFE_KEY'), + ); + const many = Object.fromEntries(Array.from({ length: 5001 }, (_, i) => [`f${i}.txt`, { content: '', mode: 'add' }])); + assert.throws( + () => createProjectFromDefinition({ schemaVersion: SCHEMA_VERSION, packkitVersion: '1.0.0', config: {}, extensions: { files: many } }), + (e) => e instanceof PackkitValidationError && e.diagnostics.some((d) => d.code === 'DEFINITION_TOO_LARGE'), + ); +}); + +test('deployment contract does not mark PORT required (it has a default)', () => { + const svc = createProject({ preset: 'node-service', name: 'svc', overrides: { env: true } }).deploymentContract; + assert.equal(svc.requiredEnvironmentVariables, undefined, 'PORT has a default, so not required'); + assert.equal(svc.port, 3000); +}); + +test('the public project has no leaked internal extension state', () => { + const p = createProject({ preset: 'ts-lib', name: 'lib' }); + assert.equal(p._extensions, undefined, 'no _extensions on the object'); + assert.deepEqual(Object.keys(p).sort(), ['config', 'deploymentContract', 'diagnostics', 'files', 'metadata', 'summary']); +}); + +test('definition replay preserves the original add/replace mode across a round-trip', () => { + // add: a host file Packkit does NOT generate must stay "add" after replay, + // so a future version that starts generating it can still flag the drift. + const added = extendProject(createProject({ preset: 'ts-lib', name: 'lib' }), { + files: { 'custom-file.txt': 'host content' }, + }); + const replayedAdd = createProjectFromDefinition(exportProjectDefinition(added)); + assert.equal(exportProjectDefinition(replayedAdd).extensions.files['custom-file.txt'].mode, 'add'); + + // replace: a deliberate override of a generated file must stay "replace". + const replaced = extendProject(createProject({ preset: 'ts-lib', name: 'lib' }), { + files: { 'package.json': 'REPLACED' }, + collisionPolicy: 'overwrite', + }); + const replayedReplace = createProjectFromDefinition(exportProjectDefinition(replaced)); + assert.equal(exportProjectDefinition(replayedReplace).extensions.files['package.json'].mode, 'replace'); +}); diff --git a/types/core.d.ts b/types/core.d.ts new file mode 100644 index 0000000..5739dab --- /dev/null +++ b/types/core.d.ts @@ -0,0 +1,96 @@ +// Public types for the pure generation core. + +export type Language = 'ts' | 'js'; +export type ModuleFormat = 'esm' | 'dual' | 'cjs'; +export type Framework = 'none' | 'react' | 'vue' | 'svelte'; +export type Target = 'library' | 'cli' | 'service' | 'app'; +export type Bundler = 'tsup' | 'tsdown' | 'unbuild' | 'rollup' | 'none'; +export type TestRunner = 'vitest' | 'jest' | 'node' | 'none'; +export type Linter = 'eslint-prettier' | 'biome' | 'oxlint' | 'none'; +export type ReleaseTool = 'changesets' | 'release-it' | 'np' | 'none'; +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +/** User-facing configuration. All fields optional on input; a preset or the + * defaults fill the rest. */ +export interface PackkitConfig { + name?: string; + description?: string; + author?: string; + keywords?: string; + repo?: string; + language?: Language; + moduleFormat?: ModuleFormat; + framework?: Framework; + target?: Target[]; + serviceFramework?: 'hono' | 'fastify' | 'express'; + monorepo?: boolean; + monorepoLayout?: 'libraries' | 'fullstack'; + packageManager?: PackageManager; + nodeVersion?: string; + bundler?: Bundler; + minify?: boolean; + test?: TestRunner; + coverage?: boolean; + storybook?: boolean; + e2e?: boolean; + sourcemaps?: boolean; + env?: boolean; + canary?: boolean; + pkgChecks?: boolean; + knip?: boolean; + sizeLimit?: boolean; + doctor?: boolean; + lint?: Linter; + gitHooks?: 'simple-git-hooks' | 'husky' | 'lefthook' | 'none'; + release?: ReleaseTool; + jsr?: boolean; + workflows?: string[]; + deps?: 'renovate' | 'dependabot' | 'none'; + license?: string; + community?: boolean; + agents?: boolean; + vscode?: boolean; + editorconfig?: boolean; + gitInit?: boolean; + install?: boolean; + [key: string]: unknown; +} + +/** The config after normalization: every field resolved, plus derived flags. */ +export interface ResolvedPackkitConfig extends PackkitConfig { + isTs: boolean; + isReact: boolean; + isVue: boolean; + isSvelte: boolean; + hasFramework: boolean; + hasApp: boolean; + hasLibrary: boolean; + hasCli: boolean; + hasService: boolean; + hasBuild: boolean; + publishable: boolean; + preset?: string; +} + +export interface ProjectSummary { + name: string; + fileCount: number; + stack: string[]; + workflows: string[]; +} + +export interface GenerateResult { + config: ResolvedPackkitConfig; + files: Record; + postCommands: string[]; + summary: ProjectSummary; +} + +export function generate(input?: PackkitConfig): GenerateResult; +export function fromPreset(name: string, overrides?: PackkitConfig): ResolvedPackkitConfig; +export function normalizeConfig(input?: PackkitConfig, diagnostics?: unknown[]): ResolvedPackkitConfig; +export function resolvePreset(name: string): string | undefined; + +export const OPTIONS: Record; +export const PRESET_NAMES: string[]; +export const PRESET_ALIASES: Record; diff --git a/types/embedded.d.ts b/types/embedded.d.ts new file mode 100644 index 0000000..20150f4 --- /dev/null +++ b/types/embedded.d.ts @@ -0,0 +1,92 @@ +// Public types for the embedded API (create-packkit/embedded). + +import type { PackkitConfig, ResolvedPackkitConfig, ProjectSummary } from './core.js'; + +export type DiagnosticSeverity = 'info' | 'warning' | 'error'; + +export interface Diagnostic { + severity: DiagnosticSeverity; + code: string; + message: string; + field?: string; + source?: string; + previousValue?: unknown; + resolvedValue?: unknown; +} + +export interface GeneratedProjectMetadata { + packkitVersion: string; + schemaVersion: number; + preset?: string; + generatedAt?: string; + extension?: Record; +} + +export type DeploymentType = 'static' | 'node-service' | 'library' | 'cli'; + +export interface DeploymentContract { + type: DeploymentType; + buildCommand?: string; + outputDirectory?: string; + startCommand?: string; + port?: number; + healthCheckPath?: string; + requiredEnvironmentVariables?: string[]; +} + +export interface GeneratedProject { + config: ResolvedPackkitConfig; + files: Record; + summary: ProjectSummary; + diagnostics: Diagnostic[]; + metadata: GeneratedProjectMetadata; + deploymentContract: DeploymentContract; +} + +export interface CreateProjectInput { + name?: string; + preset?: string; + config?: PackkitConfig; + overrides?: PackkitConfig; +} + +export type CollisionPolicy = 'error' | 'skip' | 'overwrite'; + +export interface ProjectExtension { + files?: Record; + packageJson?: Record; + metadata?: Record; + collisionPolicy?: CollisionPolicy; +} + +/** How a stored extension file relates to generated output: `add` = the host + * introduced a new path; `replace` = it deliberately overrode a generated one. */ +export interface StoredExtensionFile { + content: string; + mode: 'add' | 'replace'; +} + +export interface PackkitProjectDefinition { + schemaVersion: number; + packkitVersion: string; + preset?: string; + config: PackkitConfig; + extensions?: { + files?: Record; + packageJson?: Record; + }; +} + +export class PackkitValidationError extends Error { + readonly code: 'PACKKIT_VALIDATION_FAILED'; + diagnostics: Diagnostic[]; +} + +export const SCHEMA_VERSION: number; + +export function createProject(input?: CreateProjectInput): GeneratedProject; +export function extendProject(project: GeneratedProject, extension?: ProjectExtension): GeneratedProject; +export function exportProjectDefinition(project: GeneratedProject): PackkitProjectDefinition; +export function createProjectFromDefinition(definition: PackkitProjectDefinition): GeneratedProject; +export function calculateProjectDigest(project: GeneratedProject): string; +export function deriveDeploymentContract(config: ResolvedPackkitConfig): DeploymentContract; diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..3c6cb56 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,6 @@ +// Package root types: the generation core plus the Node embedded API. + +export * from './core.js'; +export * from './embedded.js'; +export { writeGeneratedProject, PackkitWriteError } from './writer.js'; +export type { WriteGeneratedProjectInput, WriteResult } from './writer.js'; diff --git a/types/writer.d.ts b/types/writer.d.ts new file mode 100644 index 0000000..010dfb1 --- /dev/null +++ b/types/writer.d.ts @@ -0,0 +1,25 @@ +// Public types for the writer (create-packkit/writer). + +import type { GeneratedProject, Diagnostic, CollisionPolicy } from './embedded.js'; + +export interface WriteGeneratedProjectInput { + project: GeneratedProject; + destination: string; + collisionPolicy?: CollisionPolicy; +} + +export interface WriteResult { + destination: string; + writtenFiles: string[]; + skippedFiles: string[]; + diagnostics: Diagnostic[]; +} + +export class PackkitWriteError extends Error { + code: string; + path?: string; + destination?: string; + cause?: unknown; +} + +export function writeGeneratedProject(input: WriteGeneratedProjectInput): Promise;