npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

stackfold

v2.4.0

Published

Stack-agnostic scaffolding CLI with a plugin-based generator system.

Readme

stackfold

Stack-agnostic scaffolding CLI with a plugin-based generator system.

Installation

npm install -g stackfold

Or run it without installing, via npx:

npx stackfold init electron-ts ./my-app

Requires Node.js 22+.

npm 12 and install scripts. npm 12 blocks dependency install scripts unless they are approved in allowScripts, so a global install may print 1 package had install scripts blocked ... esbuild. It is harmless: esbuild (pulled in by the tsx runtime dependency) takes its binary from the platform-specific package npm installs anyway, and install.js is only an optimisation — stackfold works without approving it. To silence the warning once, use npm install -g stackfold --allow-scripts=esbuild.

Quick start

stackfold list                     # see every available plugin
stackfold info electron-ts         # inspect one plugin's questions/help
stackfold init electron-ts ./my-app

Why

Built to stop re-solving the same app shell from scratch for each new project, but designed stack-agnostic from day one: the CLI core doesn't know about Electron, React, Vue, or plain HTML — each lives entirely in its own plugin. Adding support for another stack later means writing another plugin, not touching the core.

Built-in plugins

Run stackfold list for the live, authoritative list. Currently:

  • electron-ts — Electron + TypeScript desktop app shell (sandboxed windows, external-link policy, narrow preload bridge), with optional Settings (Dark Mode), Skins, and About/Help features. Hand-written, via the questions()/buildBlueprint() plugin contract.
  • electron-stackfold — functionally identical to electron-ts, authored declaratively via the scaffold-authoring system (see below). Kept side by side with electron-ts indefinitely; stackfold itself is init-only, so there's no forced migration for either.
  • web-app-stackfold — a web app: a single, dependency-free standalone HTML file, or a Vite-powered SPA in React, Vue, or plain vanilla TS. One appType question selects the variant.
  • plugin-builder — stackfold's own self-extension tool: scaffolds a brand-new built-in plugin (as a TS scaffold.ts + synth) and patches it straight into stackfold's own plugin registry. Only useful from a checkout of this repo — it's how stackfold extends itself, not how you author a standalone plugin.
  • plugin-scaffold — scaffolds a brand-new, standalone stackfold plugin you can author and distribute on your own, with no repo checkout required. The recommended starting point for authoring any plugin that isn't meant to live inside stackfold's own source tree — see Writing a plugin below.

Beyond the built-ins, you can register your own plugins without touching stackfold's source — see External plugins below.

Usage

stackfold init [stack] [directory]

Scaffolds a new project from a plugin into directory (created if missing; must otherwise be empty, unless --force is given — existing files are still never overwritten).

stackfold init electron-ts ./my-app

By default, init asks its questions interactively and, on success, offers to open the generated project (folder, or its main file when the plugin declares one) with your OS's default handler. Use --no-open to skip that prompt.

If a file write or a plugin's postWrite hook fails after init has already written some files, it rolls those files back by default — only the files this run itself wrote, removed in reverse order, with any empty directories left behind pruned (the target directory itself is never removed). Pass --keep-on-failure to leave the partial files in place instead, e.g. while debugging a plugin's own postWrite hook.

It also runs non-interactively, for scripts and CI:

stackfold init electron-ts ./out --config config.json
stackfold init electron-ts ./out --answer appName="CI Smoke Test" --answer includeSettings=false
  • --config <path> loads answers from a json/yaml/js file (via cosmiconfig).
  • --answer key=value sets one answer directly; repeatable. "true"/"false" become booleans, everything else stays a string.
  • --answer wins over --config on a shared key. Giving neither flag keeps the normal interactive prompts.

stackfold list / stackfold info <stack>

stackfold list              # every available plugin (built-in + registered)
stackfold info electron-ts  # one plugin's description, prerequisites, questions

A plugin's richer help (display name, details prose) is optional in the plugin contract — both commands work the same with or without it, falling back to the plugin's id and its mandatory one-line description.

stackfold scaffold synth <dir>

Compiles a TS-authored scaffold module (scaffold.ts, plus any sibling .hbs template files) into the canonical runtime artifact(s) stackfold actually loads. This is the tool plugin authors run — see Writing a plugin below.

scaffold.json is always written. JSON.stringify can't represent a function, so when a question declares a validate closure, synth also emits a sibling scaffold.js — an ESM module whose default export is the same definition, with its validate closure(s) intact — and marks scaffold.json with a top-level $synth key naming exactly which closures it lost ($synth.droppedClosures) and which module to register instead ($synth.module). Both artifacts are verified against each other (same shape, same closures present, a real compileScaffold()/ buildBlueprint() smoke test) before either is written — any mismatch and synth writes neither file. When there are no closures at all, scaffold.json is the only artifact and carries no $synth key.

Register the module, not the JSON. Whenever synth emits a scaffold.js, the command it prints at the end (stackfold plugin register <dir>/scaffold.js) is the one that keeps validate active. stackfold plugin register rejects a scaffold.json carrying a non-empty $synth.droppedClosures outright, with a message pointing at the sibling module (or, if emission was suppressed, at re-running synth) — registering it would silently resurrect exactly the "validate is undefined at runtime, so nothing is ever rejected" bug this exists to prevent.

synth only ever overwrites a scaffold.js it generated itself (its first line is a @generated banner). If a scaffold.js you wrote by hand already sits in <dir> and the definition has closures, synth stops with an error before writing anything — move your file aside, or pass --no-emit-js.

Pass --no-emit-js to suppress the module and degrade to a warning instead (the dropped closure paths are listed on stderr; exit code stays 0). plugin-builder's own generated-plugin workflow uses this flag internally, so a freshly scaffolded in-repo plugin never gets a scaffold.js shadowing its own scaffold.ts under tsx's module resolution in dev.

Because scaffold.js keeps calling loadTemplate(import.meta.dirname, ...) exactly like the TS source did, any sibling .hbs template file must stay next to scaffold.js wherever it's registered from — and if you publish the plugin to npm, make sure those .hbs files are listed in package.json's files, or they won't ship.

The scaffold.js contract

scaffold.js is an ordinary ES module. Its default export must be a single ScaffoldDefinition — normally built via defineScaffold(...) (see stackfold/authoring helpers below). A few rules follow directly from that:

  • A .ts file given directly to stackfold plugin register is rejected outright, with a message pointing you at `stackfold scaffold synth
  • Any stackfold/authoring import inside scaffold.js resolves from the plugin's own directory, following Node's normal module resolution — the stackfold package must be reachable from there (e.g. an installed dependency), not from wherever stackfold itself happens to run from.
  • A sibling .hbs template loads via loadTemplate(import.meta.dirname, ...), so it must stay next to the module wherever that module is registered from.
  • Add "type": "module" to the plugin's own package.json (what plugin-scaffold itself generates), or use a .mjs extension — don't rely on Node's ESM-syntax auto-detection.

Three literal error messages cover the broken cases above:

| Situation | Error | |---|---| | The module has no default export | stackfold plugin register: "<path>" has no default export | | A .ts file is given directly to stackfold plugin register | stackfold plugin register: "<path>" is a TypeScript scaffold source — TS is authoring-time only, never a runtime contract. Run "stackfold scaffold synth <dir>" first, then register the resulting scaffold.json. | | A stackfold/authoring import can't resolve from the plugin's own directory | stackfold plugin register: Cannot find package 'stackfold' imported from <path> |

External plugins

Register a plugin stackfold doesn't ship with, without editing stackfold's own source:

stackfold plugin register ./path/to/scaffold.json      # or scaffold.js
stackfold plugin register some-npm-package              # an installed npm package
stackfold plugin register ./path/to/scaffold.json --project  # project-level instead of global

stackfold plugin list                                    # see what's registered, and its resolution status
stackfold plugin unregister <id>                         # remove a registration

Registration validates the source before writing anything (shape-check plus a real smoke test — questions()/buildBlueprint() invoked with synthesized answers) and records the plugin's real id, never a user-chosen label. A .ts/.tsx source is rejected outright — run stackfold scaffold synth <dir> first.

Config lives at two levels, both resolved via cosmiconfig:

  • Global — ~/.stackfoldrc.json, available in every project.
  • Project — the nearest config file found by searching upward from the current directory, stopping at (and including) the first ancestor directory that contains a package.json, or the filesystem root if none does. plugin register --project run from a subdirectory writes to that existing ancestor config file rather than creating a new one. An intermediate directory with its own package.json stops the search there, so a project config never leaks across that boundary. The project config wins over the global config on a same-id collision.

When --project finds no existing project-level config to write to, it creates .stackfold-pluginsrc.json in the current directory instead. Either way, stackfold plugin register prints the exact file it wrote to.

A relative local path is resolved against the directory you run register from, and the entry is stored so it keeps pointing at that same file: an absolute path in the global config, a path relative to the config file in a project config (so a committed project config stays portable). npm package specifiers are stored as given.

A built-in plugin always wins an id collision against an external one. A broken external entry (deleted file, uninstalled package, failed validation) is skipped with a warning rather than breaking list/init for everyone else.

Writing a plugin

The recommended path is plugin-scaffold — it needs no checkout of this repo, works from any directory, and produces a standalone plugin you can register anywhere:

stackfold init plugin-scaffold ./my-plugin

The directory argument must match the pluginId answer (default my-plugin, which is why the example above works). If it doesn't, the plugin's postWrite hook fails with a message ending in Re-run: stackfold init plugin-scaffold <pluginId> — rerun init with the directory and the pluginId answer set to the same value.

It scaffolds a new, standalone plugin at ./my-plugin/, authored as a TS scaffold.ts (importing the authoring DSL from the installed stackfold package itself — see Authoring a plugin without a repo checkout below) plus sibling .hbs templates and its own package.json, synthesizes its canonical scaffold.json via a real stackfold scaffold synth call, and prints the exact stackfold plugin register command to run next.

If you're extending stackfold itself — adding a new built-in plugin to this repo's own source tree — use plugin-builder instead:

stackfold init plugin-builder   # from a checkout of this repo

It scaffolds a new built-in plugin under src/plugins/<id>/ the same way, but registers it by patching src/cli/index.ts directly — which is why it only works inside a checkout.

Under the hood, a scaffold author describes a plugin declaratively: questions (optionally with when conditions and deriveAnswer steps that compute an answer from others — e.g. slugify a name), an ordered list of ActionSteps (mkdir, writeFile, runCommand, runHookCommand, npmInstall, gitInit, plus any registerAction() extension), and optional lifecycle hooks (preInit, postWrite, postInit). File content in writeFile is templated with Handlebars — never eval. Reusable pieces ("fragments") can be mounted into a scaffold via mountFragment()/composeFragments(), each namespaced Terraform-module-style so two fragments never collide on a question id.

This authoring system is how 4 of the 5 built-in plugins (electron-stackfold, web-app-stackfold, plugin-builder, plugin-scaffold) are built today. electron-ts is hand-written instead, via the questions()/buildBlueprint() plugin contract directly. Both are kept side by side indefinitely — see Built-in plugins above — and stackfold itself is init-only, so neither needs to migrate to the other. See the CHANGELOG for the full release history.

Authoring a plugin without a repo checkout

plugin-scaffold (above) is exactly this: run stackfold init plugin-scaffold ./my-plugin from anywhere — no checkout, no access to stackfold's own source — and you get a real, TS-authored plugin synthesized by the same stackfold scaffold synth pipeline plugin-builder uses internally, ready for stackfold plugin register. If any question declares a validate closure, synth also writes a sibling scaffold.js carrying it — register that file, not scaffold.json, to keep validate active (see stackfold scaffold synth <dir> above).

If you'd rather skip TypeScript entirely, you can hand-write a scaffold.json directly against the schema documented below, then run stackfold plugin register. This is not a workaround — it's the same file plugin-scaffold/scaffold synth produce for you (when there are no validate closures to carry), and it's fully supported by stackfold plugin register/list/info/init. You're skipping the TS-authoring step and writing the compiled artifact directly — which means no validate closure is available this way at all. Declarative constraints (see the QuestionConstraints section below) are plain JSON data, not a closure, so they work fine in a hand-written scaffold.json — only validate requires TypeScript.

Package entry points

stackfold is a CLI first. Its only importable package entry point is stackfold/authoring (defineScaffold, file, loadTemplate, ...), the surface a TS-authored plugin imports. import 'stackfold' and deep imports such as stackfold/dist/... or stackfold/package.json fail with ERR_PACKAGE_PATH_NOT_EXPORTED. If you need something that isn't exported, open an issue instead of reaching into dist/.

stackfold/authoring helpers

| Export | Kind | Purpose | |---|---|---| | defineScaffold | function | Builds a ScaffoldDefinition from { id, description, help?, questions, steps, hooks?, mainFile? }. | | file | function | Builds a writeFile step — see the options table below. | | mkdir | function | Builds a mkdir step: mkdir(path, { when? }). | | runCommand | function | Builds a runCommand step: runCommand(command, { args?, when? }). | | npmInstall | function | Builds an npmInstall step (no params). | | gitInit | function | Builds a gitInit step (no params). | | step | function | Escape hatch for any other registered action: step(action, params, { when? }). | | loadTemplate | function | Reads a sibling .hbs template: loadTemplate(import.meta.dirname, 'templates/x.hbs'). | | toValidate | function | Adapts a zod string schema into a validate closure. | | appNameSchema / descriptionSchema / authorSchema / pluginIdSchema | zod schema | Reusable schemas for common question answers. | | appNameConstraints / descriptionConstraints / authorConstraints / pluginIdConstraints | QuestionConstraints | Declarative equivalents of the schemas above, assignable directly to a question's constraints field. | | MAX_PACKAGE_NAME_LENGTH | constant | The length limit the npm-package-name-shaped schemas/constraints above enforce. |

defineScaffold's own input type (DefineScaffoldInput), plus each step helper's options type (FileStepOptions, RunCommandStepOptions, StepOptions, WriteFileStrategy), are also exported for type-only use.

file's options (FileStepOptions):

| Option | Type | Notes | |---|---|---| | strategy | "exclusive" | "json-merge" | "append" | What happens on a path collision with another step. Defaults to "exclusive" (a collision is an error). | | jsonEscapeKeys | string[] | Answer ids (not file paths) whose value is escaped for a JSON-string position before rendering. | | jsEscapeKeys | string[] | Answer ids whose value is escaped for a single-quoted-JS-string position. | | htmlEscapeKeys | string[] | Answer ids whose value is escaped for an HTML position. | | scriptCloseEscapeKeys | string[] | Answer ids whose value is escaped for a Vue SFC </script>-safe position. | | when | boolean | JSON Logic expression | Skips the step entirely when it evaluates falsy. |

The four *EscapeKeys arrays name question/answer ids, not arbitrary strings — each entry must match a question's id whose answer value gets escaped, never a free-form string of your choosing.

The ScaffoldDefinition shape

A scaffold.json is one JSON object with these top-level fields:

| Field | Type | Required | Notes | |---------------|------------------------------------|----------|-------| | id | string | yes | The plugin's real id — what stackfold list/init <id> use. | | description | string | yes | One-line description shown in stackfold list. | | help | { name?: string, details?: string } | no | Richer stackfold info output. name falls back to id; details is only shown when present. | | questions | QuestionDefinition[] | yes (may be []) | Prompts asked by stackfold init. | | steps | ActionStep[] | yes (may be []) | The ordered list of file-writing/process actions that build the project. | | hooks | { preInit?, postWrite?, postInit?: ActionStep[] } | no | Lifecycle actions — see "Hook-only actions" below. | | mainFile | string | JSON Logic expression | no | Relative path (from the generated project root) stackfold init offers to open on success. |

QuestionDefinition

{
  id: string;            // the key this answer is stored under
  message: string;       // prompt text
  type: "input" | "confirm" | "select";
  choices?: string[];    // required when type === "select"
  default?: string | boolean | number;
  constraints?: QuestionConstraints; // "input" only — see below
}

Note: the plugin contract also supports a validate function on a question, but a function has no JSON representation — a hand-written scaffold.json can never carry one. A plugin authored as TypeScript (scaffold.ts via plugin-builder/plugin-scaffold) can: stackfold scaffold synth emits a sibling scaffold.js module carrying every validate closure intact whenever one is declared, and marks scaffold.json so stackfold plugin register refuses to accept it in place of that module — see [`stackfold scaffold synth

validate's signature is (value: AnswerValue) => true | string, where AnswerValue = string | boolean | number. It must be pure (no I/O), and returns either true or a string error message. When a question declares both constraints and validate, constraints runs first — validate only runs if constraints passes (see QuestionConstraints below).

stackfold/authoring doesn't export AnswerValue/QuestionDefinition directly, so reference validate's type through DefineScaffoldInput instead:

import type { DefineScaffoldInput } from 'stackfold/authoring';

type Validate = NonNullable<DefineScaffoldInput['questions'][number]['validate']>;

QuestionConstraints

A declarative alternative (or complement) to validate, for a type: "input" question only — valid on any other question type is a compile/ register-time error. Unlike validate, it's plain data, so it survives stackfold scaffold synth into scaffold.json and works in a hand-written scaffold.json with no scaffold.js needed.

{
  trim?: boolean;
  minLength?: { value: number; message: string };
  maxLength?: { value: number; message: string };
  rules?: Array<
    | { matches: string; message: string }     // regex source, no flags
    | { notMatches: string; message: string }
  >;
}
  • trim — when true, every check below evaluates the trimmed value; the stored answer itself is never altered.
  • minLength / maxLength — reject when the value's length is below/above value. Length is counted in Unicode code points, not UTF-16 units — an emoji or other astral character counts as one, not two.
  • rules — regex checks, evaluated in array order. matches fails when the value does not contain a match; notMatches fails when it does. The regex source string carries no flags.

Checks run in a fixed order — trim (if set) -> minLength -> maxLength -> rules in declared order — and the first one that fails wins: its message is returned, and no later check runs.

If a question declares both constraints and validate, constraints runs first; validate only runs if constraints passes.

A malformed constraints (invalid regex, a non-integer or negative length limit, minLength greater than maxLength, an empty message, an unknown key, or constraints on a non-"input" question) is rejected at compile/synth/register time — never silently accepted, and never surfacing as a confusing runtime error to someone filling out a prompt.

Trust model: regex sources in rules are author-supplied and run with no timeout or sandbox, the same trust model templates and hooks already have (an author already executes arbitrary code at register time) — a pathological regex can hang validation for that author's own plugin.

ActionStep

Every entry in steps, and every entry inside hooks.<lifecycle>, has this shape:

{
  action: string;    // one of the built-in action names below
  params: object;    // shape depends on the action — see "Built-in actions"
  when?: boolean | JsonLogicExpression; // optional guard, evaluated against answers
}

when is evaluated against the collected answers before the step runs; if it evaluates falsy, the step is skipped entirely (its params are never even parsed). Omit when for a step that should always run.

Expressions inside params

Most fields inside params may be either a literal value or a JSON Logic expression evaluated against answers — for example { "var": "appName" } to read an answer, or { "cat": [{ "var": "pluginId" }, "/README.md"] } to build a path from one. A few specific fields are always treated as literal, author-written text and are never expression-resolved (documented per-action below) — this is deliberate, so a mutation target (a file path, a patch anchor) can never be redirected by end-user input.

Built-in actions

Actions come in two kinds: compose/process actions, which may only appear in steps, and hook actions, which may only appear inside hooks (see the next section). Putting a hook action in steps, or a compose/process action under hooks, is a validation error.

| Action | Kind | Where | params | |---|---|---|---| | mkdir | compose | steps | { path: string } | | writeFile | compose | steps | { path: string, content: string, strategy?: "exclusive" \| "json-merge" \| "append", jsonEscapeKeys?: string[], jsEscapeKeys?: string[], htmlEscapeKeys?: string[], scriptCloseEscapeKeys?: string[] } | | runCommand | process | steps | { command: string, args?: string[] } | | npmInstall | process | steps | {} (no params) | | gitInit | process | steps | {} (no params) | | patchFile | hook | hooks only | { path: string, anchor: string, insert: string, position: "before" \| "after" } | | deriveAnswer | hook | hooks only | { from: string, to: string, transform: "npm-slug" \| "js-identifier-suffix" \| "current-year", suffix?: string } | | runHookCommand | hook | hooks only | { command: string, args?: string[] } |

Details and literal-field exceptions, per action:

  • mkdir — creates an (otherwise empty) directory at path, relative to the generated project root.
  • writeFile — writes content to path, relative to the project root. content is rendered as a Handlebars template against the answers (e.g. {{appName}}) — content itself is always a literal string, never expression-resolved (it's the Handlebars template source, not a value to compute). strategy controls what happens if another step already wrote the same path (default is effectively "exclusive" — a collision is an error). jsonEscapeKeys/jsEscapeKeys/htmlEscapeKeys/scriptCloseEscapeKeys each name a list of answer keys (not file paths) whose string value gets escaped for a JSON-string / single-quoted-JS-string / HTML / Vue SFC </script>-safe position before the template renders — use these whenever a free-text answer lands inside a position where its literal text could otherwise break the generated file. These four list fields are also always literal (never expression-resolved).
  • runCommand — runs command with args as a post-action, with cwd set to the generated project's own directory.
  • npmInstall / gitInit — run npm install / git init as a post-action in the generated project directory. Take no params.
  • patchFile (hook only) — inserts insert immediately before/after the line containing anchor in the existing file at path. Idempotent (running it again when insert is already present is a no-op). path is resolved relative to the real process's current working directory, not the generated project's targetDir, and anchor/path are always literal — this action exists to patch a file that already exists outside the generated project (e.g. registering a new plugin into a CLI's own source), which is why it's a hook rather than a steps action.
  • deriveAnswer (hook only) — computes answers[to] from answers[from] via a fixed transform: npm-slug (npm-package-name-safe slug), js-identifier-suffix (a valid JS identifier; suffix is required for this transform and appended to disambiguate from real globals), or current-year (ignores from, writes the current year). from/to/transform/suffix are always literal.
  • runHookCommand (hook only) — runs command/args with no cwd override (inherits the real invoking process's cwd) — unlike runCommand, which always runs inside the generated project.

Hook-only actions and hooks

hooks is optional and, when present, keys its actions by lifecycle moment:

{
  preInit?: ActionStep[];   // after answers are collected, before files are built
  postWrite?: ActionStep[]; // after files are written, before post-actions run
  postInit?: ActionStep[];  // after post-actions have run
}

Only patchFile, deriveAnswer, and runHookCommand may appear here (or any action registered elsewhere as a "hook" kind) — never mkdir/writeFile/runCommand/npmInstall/gitInit.

A complete, minimal example

Save this as scaffold.json and register it directly — no build step, no repo checkout required:

{
  "id": "hello-world",
  "description": "A minimal example plugin: a package.json and a README.",
  "questions": [
    {
      "id": "projectName",
      "message": "Project name",
      "type": "input",
      "default": "my-app"
    }
  ],
  "steps": [
    {
      "action": "writeFile",
      "params": {
        "path": "package.json",
        "content": "{\n  \"name\": \"{{projectName}}\",\n  \"version\": \"0.1.0\"\n}\n",
        "jsonEscapeKeys": ["projectName"]
      }
    },
    {
      "action": "writeFile",
      "params": {
        "path": "README.md",
        "content": "# {{projectName}}\n\nGenerated by the hello-world stackfold plugin.\n"
      }
    },
    {
      "action": "gitInit",
      "params": {}
    }
  ]
}
stackfold plugin register ./scaffold.json
stackfold list                     # "hello-world" now shows up
stackfold info hello-world
stackfold init hello-world ./my-app

If you do author a plugin as TypeScript (scaffold.ts) instead of hand-writing JSON, stackfold scaffold synth <dir> writes the resulting scaffold.json directly into <dir>, next to the scaffold.ts it compiled — plugin-scaffold relies on exactly this when it calls synth on its own freshly generated plugin. If any question declares a validate closure, a sibling scaffold.js is written too, and that's the file to register — see stackfold scaffold synth <dir> above.

Architecture

Clean-architecture-ish layering, dependencies point inward:

src/
  core/       domain types + pure orchestration logic (no I/O):
              types, orchestrator, the declarative scaffold-authoring
              compiler (authoring.ts), validation
  plugins/    one folder per built-in stack (electron-ts,
              electron-stackfold, web-app-stackfold, plugin-builder,
              plugin-scaffold), each implementing the plugin contract
  infra/      adapters: file system, git/process execution (execa),
              prompts (@inquirer), the composite + dynamic plugin
              registries, expression evaluation (json-logic-js)
  cli/        composition root — commander wiring, entry point

core/ has zero dependencies on infra/ or plugins/; it only depends on its own types. cli/ is the only layer allowed to wire concrete adapters into the orchestrator.

Four ports define the boundary between core/ and infra/: Prompter, FileWriter, ProcessRunner, and ExpressionEvaluator (a restricted, non-Turing-complete json-logic-js expression, never an eval). Plugin resolution goes through a fourth port, PluginRegistry, which resolves asynchronously, so that resolving an external plugin never has to eagerly load every other registered plugin, only the one actually requested.

Stack

Development

npm install
npm run dev -- init electron-ts ./tmp/out     # run the CLI without building
npm run dev -- list
npm run build                                  # tsc -> dist/, then copies .hbs templates
npm test                                       # vitest
npm run test:coverage
npm run typecheck                              # tsc --noEmit

License

MIT — see LICENSE.