stackfold
v2.4.0
Published
Stack-agnostic scaffolding CLI with a plugin-based generator system.
Maintainers
Readme
stackfold
Stack-agnostic scaffolding CLI with a plugin-based generator system.
Installation
npm install -g stackfoldOr run it without installing, via npx:
npx stackfold init electron-ts ./my-appRequires 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 print1 package had install scripts blocked ... esbuild. It is harmless:esbuild(pulled in by thetsxruntime dependency) takes its binary from the platform-specific package npm installs anyway, andinstall.jsis only an optimisation — stackfold works without approving it. To silence the warning once, usenpm 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-appWhy
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 thequestions()/buildBlueprint()plugin contract.electron-stackfold— functionally identical toelectron-ts, authored declaratively via the scaffold-authoring system (see below). Kept side by side withelectron-tsindefinitely;stackfolditself 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. OneappTypequestion selects the variant.plugin-builder— stackfold's own self-extension tool: scaffolds a brand-new built-in plugin (as a TSscaffold.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-appBy 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=valuesets one answer directly; repeatable."true"/"false"become booleans, everything else stays a string.--answerwins over--configon 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, questionsA 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
.tsfile given directly tostackfold plugin registeris rejected outright, with a message pointing you at `stackfold scaffold synth - Any
stackfold/authoringimport insidescaffold.jsresolves from the plugin's own directory, following Node's normal module resolution — thestackfoldpackage must be reachable from there (e.g. an installed dependency), not from whereverstackfolditself happens to run from. - A sibling
.hbstemplate loads vialoadTemplate(import.meta.dirname, ...), so it must stay next to the module wherever that module is registered from. - Add
"type": "module"to the plugin's ownpackage.json(whatplugin-scaffolditself generates), or use a.mjsextension — 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 registrationRegistration 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 --projectrun from a subdirectory writes to that existing ancestor config file rather than creating a new one. An intermediate directory with its ownpackage.jsonstops 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-pluginThe 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 repoIt 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
validatefunction on a question, but a function has no JSON representation — a hand-writtenscaffold.jsoncan never carry one. A plugin authored as TypeScript (scaffold.tsviaplugin-builder/plugin-scaffold) can:stackfold scaffold synthemits a siblingscaffold.jsmodule carrying everyvalidateclosure intact whenever one is declared, and marksscaffold.jsonsostackfold plugin registerrefuses 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— whentrue, every check below evaluates the trimmed value; the stored answer itself is never altered.minLength/maxLength— reject when the value's length is below/abovevalue. 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.matchesfails when the value does not contain a match;notMatchesfails when it does. The regexsourcestring 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 atpath, relative to the generated project root.writeFile— writescontenttopath, relative to the project root.contentis rendered as a Handlebars template against the answers (e.g.{{appName}}) —contentitself is always a literal string, never expression-resolved (it's the Handlebars template source, not a value to compute).strategycontrols what happens if another step already wrote the samepath(default is effectively"exclusive"— a collision is an error).jsonEscapeKeys/jsEscapeKeys/htmlEscapeKeys/scriptCloseEscapeKeyseach 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— runscommandwithargsas a post-action, withcwdset to the generated project's own directory.npmInstall/gitInit— runnpm install/git initas a post-action in the generated project directory. Take no params.patchFile(hook only) — insertsinsertimmediately before/after the line containinganchorin the existing file atpath. Idempotent (running it again wheninsertis already present is a no-op).pathis resolved relative to the real process's current working directory, not the generated project'stargetDir, andanchor/pathare 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 astepsaction.deriveAnswer(hook only) — computesanswers[to]fromanswers[from]via a fixed transform:npm-slug(npm-package-name-safe slug),js-identifier-suffix(a valid JS identifier;suffixis required for this transform and appended to disambiguate from real globals), orcurrent-year(ignoresfrom, writes the current year).from/to/transform/suffixare always literal.runHookCommand(hook only) — runscommand/argswith nocwdoverride (inherits the real invoking process's cwd) — unlikerunCommand, 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-appIf you do author a plugin as TypeScript (
scaffold.ts) instead of hand-writing JSON,stackfold scaffold synth <dir>writes the resultingscaffold.jsondirectly into<dir>, next to thescaffold.tsit compiled —plugin-scaffoldrelies on exactly this when it callssynthon its own freshly generated plugin. If any question declares avalidateclosure, a siblingscaffold.jsis written too, and that's the file to register — seestackfold 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 pointcore/ 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
- commander — CLI framework
- @inquirer/prompts — interactive prompts
- execa — process execution
- cosmiconfig — plugin/config discovery
- zod — validation
- handlebars — file content templating in the scaffold-authoring system
- json-logic-js — expression evaluation behind the
ExpressionEvaluatorport - picocolors — terminal color (zero dependencies)
- TypeScript 6.x
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 --noEmitLicense
MIT — see LICENSE.
