mold
Pack an existing project into a portable template archive, then unpack it under
a new name. Ships a mold CLI and is consumable as a library.
The template is a real project — one that builds, runs and has tests. There are no placeholders in the source: renaming rules live in a manifest beside it, so the template stays lintable, reviewable and current by being the thing you actually use. The archive is a verbatim snapshot; substitution happens only at unpack, so one archive materializes under many names.
mold pack ./super_server -> super_server.mold (gzipped tar)
├── mold.yaml (the manifest, embedded)
└── files/ (the project, verbatim)
mold unpack ./super_server.mold --var project_name=my_project
-> ./my_project/ (renamed: paths + text,
binaries copied as-is)
Install
As a CLI
dart install mold
That puts mold on your PATH. To pin a version, or to install before the
package is on pub.dev:
dart install mold@^0.2.0
# straight from git
dart install 'mold@{git: {url: https://github.com/pro100andrey/mold}}'
# from a local checkout
dart install 'mold@{path: /path/to/mold}'
On Dart SDKs older than the dart install command, use the classic form:
dart pub global activate mold
Check it: mold --help. If the command is not found, the directory it was
installed into is not on your PATH; dart install prints the line that adds
it. That directory is Dart's own, not pub's: ~/.local/state/Dart/install/bin
on Linux (under $XDG_STATE_HOME when set),
~/Library/Application Support/Dart/install/bin on macOS,
%LOCALAPPDATA%\Dart\install\bin on Windows. dart pub global activate
uses pub's: ~/.pub-cache/bin, or %LOCALAPPDATA%\Pub\Cache\bin on Windows.
As a library
dart pub add mold
import 'package:mold/mold.dart';
Requirements
Dart SDK 3.13.5 or newer. No other runtime dependencies — mold shells out
to chmod on POSIX to restore executable bits, and does nothing at all if that
is unavailable.
Contents
- Install · Quick start · Commands ·
init - The manifest — file selection · variables · substitutions · path renames · verbatim files · message
- Interpolation and transforms
- Choosing a
replacestoken - Previewing
- Validation · exit codes
- Library usage · embedding
- What is preserved · out of scope
Quick start
# 1. Describe the rename beside the project you already have —
# `mold init ./super_server` writes a start — or by hand:
cat > ./super_server/mold.yaml <<'YAML'
name: super_server
version: 1.0.0
variables:
project_name:
description: The new project name
default: my_project
replaces: super_server
YAML
# 2. See what it would do before it does it.
mold pack ./super_server --dry-run
# 3. Pack, then scaffold under any name.
mold pack ./super_server -o super_server.mold
mold unpack super_server.mold -t ./my_project --var project_name=my_project
Commands
mold init [dir] [options]
--token The name to replace (default: detected, see below)
--variable The variable replacing it (default: project_name)
--yes, -y Add the suggested entries without asking
--no-prompt Never ask; leave the suggestions out
--force Overwrite an existing mold.yaml
--dry-run Print the manifest on stdout instead of writing it
--json Print the result as JSON on stdout
mold pack <source_dir> [options]
--manifest, -m Path to the manifest (default: <source_dir>/mold.yaml;
error if absent and -m not given)
--output, -o Output path (default: ./<name>.mold, or
./<name>.dart for embed formats)
--name, -n Output file name stem (also names the embedded const)
--format, -f tar.gz | bytes | base64 (default: tar.gz)
--level gzip level 0-9 (default: 6)
--fast zlib instead of pure-Dart deflate (faster; bytes vary by OS)
--var, -v key=value (repeatable, for --diff)
--vars-file YAML/JSON values, or a stamp (for --diff)
--dry-run Validate and list what would be captured, without writing
--diff Preview the renames this manifest would make
--check Exit 1 if the output file is stale; write nothing
--json Print the result as JSON on stdout
mold unpack <source | -> [options]
--target, -t Destination dir (default: ./<source-stem>)
--var, -v key=value (repeatable; overrides the file)
--vars-file YAML/JSON values, or a .mold.json stamp
--no-prompt Use only --var + manifest defaults; never prompt
--force Unpack into a non-empty target (see below)
--stamp Record the unpack in <target>/.mold.json
--dry-run Show what would be written, without writing it
--diff Also show a unified diff (implies --dry-run)
--json Print the plan as JSON on stdout
mold inspect <archive | -> [options]
--files Also list every file, with its size
--manifest Print the embedded mold.yaml as written, on stdout
--json Print the description as JSON on stdout
mold help <command> for the full option list. During development:
dart run mold:mold pack ./super_server.
Human-facing output goes to stderr; stdout carries only what a tool would
read — --json documents, and the manifest YAML of init --dry-run and
inspect --manifest. Under --json, warnings are part of the document, and a failure is a document
too — {"errors": [{"code": …, "message": …}], "warnings": […]} with exit 1 —
so a tool reading stdout always gets JSON. Failures that carry no library code
use CLI_USAGE (bad arguments, a missing vars file — exit 64) and
IO_FAILURE (exit 1).
Starting a manifest
mold init ./app_template writes a starting mold.yaml: the project's own
name as the replaces token of one variable, project_name, and the manifest
itself excluded from what it packs. The name is read from the project's
package manifest — pubspec.yaml, package.json, composer.json,
Cargo.toml, go.mod, pyproject.toml, settings.gradle(.kts), pom.xml,
Package.swift or a lone .csproj, .fsproj or .vbproj — else taken from the directory;
--token names it instead.
It then runs the pack's checks on that draft. Spellings of the name the four
derived casings miss are offered as entries — extra_substitutions for those
in file content, path_renames for those in paths, both when they are in
both. On a fresh Flutter project, the bundle id and the display name:
Spellings of the name no derived casing covers:
'appTemplate' → {{ project_name | camelCase }} (2 files)
'App Template' → {{ project_name | titleCase }} (1 file)
Add them to extra_substitutions? [Y/n]
Everything else the pack would say — a token too generic, a suspected secret
— is reported as a warning, and the manifest is written anyway: it is a start,
to review with mold pack ./app_template --dry-run.
Unpacking into a non-empty directory
By default the target must not exist, or be an empty directory; a directory
with files in it is TARGET_OCCUPIED. Missing parents are fine — the target
and every directory on the way to it are created, and a --dry-run into a new
nested directory checks them without creating any. A file at the target, or
on the way to it, is TARGET_NOT_DIRECTORY.
--force writes into a directory that already holds files — the way a tool
regenerates a project in place:
- each file the template has is written over the file at the same path;
- every file the template does not have is left as it is;
- a target that is a file, not a directory, is still refused;
- a template file that would land on a directory, under a file, or through a
symlink is
TARGET_PATH_CONFLICT, reported before anything is written.
--force is not a merge: an overwritten file holds the template's version,
whatever was edited in it. Preview first — --force --dry-run lists what
would be created and what overwritten, and --force --diff diffs each
overwritten file against the one already there.
If a write still fails partway (a read-only file, a full disk), the unpack is undone: created files are removed, overwritten files get their previous content back, and directories it created are removed once empty. Anything it could not undo is named in a warning. Writes go straight into the target rather than through a scratch directory: moving a tree into an occupied directory is one rename per file, so it would be no more atomic, and a scratch directory can sit on another filesystem, where a rename is a copy.
Reading the archive from stdin
- in place of the archive reads it from stdin, so a template on a server
needs no intermediate file:
curl -sL https://example.com/app.mold | mold unpack - -t ./my_app --var name=my_app
stdin then carries the archive, so nothing can be prompted for: values come
from --var, --vars-file and defaults, as under --no-prompt, and
--target is required — there is no file name to derive it from.
mold inspect - works the same way.
Inspecting a template
mold inspect super_server.mold says what a template is before you run it: its
name and version, the variables it asks for (with defaults, patterns and
choices), its explicit substitutions, and — with --files — what it holds. It
runs the archive and manifest checks an unpack runs, so a template that
inspects cleanly can fail an unpack only on its variables or its target.
--manifest prints the embedded mold.yaml itself, as written — on stdout,
so mold inspect app.mold --manifest > mold.yaml recovers it.
Checking a committed archive in CI
mold pack ./templates/app -f bytes -o lib/src/template.dart --check
Exit 0 when the file holds what packing would write now, 1 when it is stale or
missing — with what changed: 1 changed (lib/main.dart); 1 added (…).
Nothing is written.
The comparison is by content: the archive is unpacked from the committed
file (from inside the Dart source, for -f bytes|base64) and its manifest,
files and executable bits compared with the project's. Compressed bytes are
not compared, so a file written on another OS, by another Dart SDK, at another
--level or with --fast is not called stale when its content is current.
An embed source must also be exactly what this mold renders around its
archive, so a changed -n or a newer mold's layout is stale too. Line endings
are not a change: a source committed with LF and checked out with CRLF — Git
for Windows' default — is current.
Nothing is compressed for the check: the project's files are compared with the committed archive's as they are read. On a 48 MB project it takes 0.7 s, a third of a pack.
Reproducible archives
Packing the same tree and manifest at the same --level gives the same bytes
on any OS and Dart SDK — for a given version of mold and package:archive —
so regenerating a committed -f bytes source on another machine is an empty
diff. Two things are pinned for it: compression uses package:archive's
pure-Dart deflate rather than the zlib built into the VM, whose output can
vary by platform; and every gzip header is the same ten bytes (no mtime, OS
"unknown"), where zlib writes a per-platform OS byte. CI checks this on Linux,
macOS and Windows against committed golden archives.
--fast compresses with the VM's zlib instead: about 2.7x faster at that
step (0.66 s against 1.8 s for a 48 MB tar), still reproducible on one
machine, and still fine for --check — but no longer byte-identical across
platforms.
Regenerating a project
--stamp leaves a .mold.json in the target: the template, its version, the
variable values and every file written. A target that already has one gets it
updated on every later --force, whether or not --stamp is repeated.
mold unpack app.mold -t ./my_app --var name=my_app --stamp
# …later, with a newer template:
mold unpack app.mold -t ./my_app --force --vars-file ./my_app/.mold.json --dry-run
--vars-file takes the stamp's values, so a regeneration does not depend on
remembering what was typed the first time; --var still overrides any of
them. A variable the template has dropped since is VARIABLE_DROPPED ⚠ — its
value is not used — rather than an error that would make the stamp useless
after the first change to the template's variables. The stamp also records a SHA-256 of
every file it wrote, which tells a regeneration two things:
- which files it is about to overwrite hold content it did not write — edited
since, or never written by it at all (the project's own file, at a path the
template has added since) —
UNPACK_OVERWRITES_EDITS, listed in a dry run, so--forceno longer discards work without a word; - which files the template has since dropped — listed under "No longer in
the template", reported as
UNPACK_STALE_FILES, and left in place: never deleted, since they may have been edited. They stay in the stamp while they exist, so every regeneration reports them, not just the first.
A regeneration rewrites only files that change: one identical to what is
there is compared, not written, so mtimes — and build caches and file
watchers — are left alone. A stamp at a project's root is never packed into a
template: it describes one unpack into one directory, and would hand every
later unpack its author's values and history. Only a stamp — a .mold.json
that is some other tool's file is packed like any other.
A stamp belongs to one template. Unpacking another into the same directory
with --force — an add-on template into a project made from a first one —
reports UNPACK_STAMP_FOREIGN ⚠ and leaves the first one's stamp as it is:
it is not used to tell stale or edited files, which would call every file of
the first template stale, and not rewritten for the second. With --stamp it
is an error, since one .mold.json cannot describe both.
On macOS and Windows, a path whose casing changed between unpacks
(MyApp.swift → Myapp.swift, after a value's casing changed) is the same
file, and treated as one: neither stale nor the project's own.
--vars-file also takes a plain YAML or JSON map of values. --var applies
over it; a key the template does not declare is VARIABLE_UNKNOWN, as for
--var — a map is typed by hand, so an unknown key there is more likely a
typo than a dropped variable.
The manifest (mold.yaml)
Every key, with its default:
# ─── required ───────────────────────────────────────────────
name: super_server # template name; default output file stem
version: 1.0.0 # free-form string
# ─── file selection ─────────────────────────────────────────
use_gitignore: true # honour the project's own .gitignore files
include: [] # globs; empty means "everything"
exclude: [] # globs, applied on top of .gitignore
# ─── renaming ───────────────────────────────────────────────
variables: # {} — declared rename variables
project_name:
description: '' # shown when prompting
default: null # absent → the variable is required
replaces: null # the source token; absent → interpolation only
pattern: null # regex the whole value must match
choices: [] # the only values allowed; empty → any
extra_substitutions: [] # from/to, applied to CONTENT only
path_renames: [] # from/to, applied to PATHS only
# ─── copied verbatim ────────────────────────────────────────
no_substitute: [] # globs; content untouched, path still renamed
binary_extensions: [] # extra extensions treated as binary
# ─── after unpacking ────────────────────────────────────────
message: null # shown once unpacked; a template like `to:`
Values are read as written: an unquoted version: 1.10 is 1.10, not the
number YAML would make of it (1.1), and 007 stays 007 — in the manifest
and in --vars-file alike.
A key the format does not define is ignored, but never silently: it warns as
MANIFEST_UNKNOWN_KEY, with the key it probably means — excludes → did you
mean exclude? — at the top level, inside a variable, and inside a
substitution entry.
File selection
A file is packed only if it passes include, passes exclude, and is
not gitignored. Three independent filters, all must pass.
use_gitignore (default true) reads the project's own .gitignore files —
all of them, including nested per-platform ones — so a manifest never restates
.dart_tool/**, build/** and friends. The supported syntax is the real
thing: ! negation, / anchoring, trailing-slash directory rules, #
comments, and last-match-wins ordering. .git/ is always excluded. Set
use_gitignore: false to pack the tree exactly as it sits on disk.
exclude therefore carries only what git has no opinion about:
exclude:
- pubspec.lock # committed, but a scaffold resolves its own
- mold.yaml # the template's own manifest
Symlinks: one pointing at a file inside the project and passing the same
filters is packed as its content. One pointing outside, at a directory, at
nothing, or at a filtered-out file is skipped with a PROJECT_SYMLINK_SKIPPED
warning. Following a link out of the project would let pack inline
~/.ssh/id_rsa into a template you then share.
Variables
variables:
project_name:
description: The new project name
default: my_project
replaces: super_server
replaces names a token in the source. At unpack, the resolved value replaces
four derived casings of it, in both paths and content:
| casing | super_server → |
|---|---|
| snake_case | super_server |
| kebab-case | super-server |
| SCREAMING_SNAKE | SUPER_SERVER |
| PascalCase | SuperServer |
The source spelling does not matter: super_server, superServer,
SuperServer and SUPER_SERVER all declare the same rename.
camelCase is deliberately not derived. For a single-word token it is indistinguishable from snake_case, so deriving it would be ambiguous — reach it with an explicit transform instead (below).
The value given for a replaces variable must be something the casings can be
derived from: ASCII letters, digits, spaces, _ and -, starting with a
letter or digit and holding at least one letter — 3d-viewer is fine; 123,
-app and my/app are VARIABLE_INVALID_FORMAT.
Anything a particular language forbids on top of that, such as a leading digit
in a Dart package name, belongs in the variable's pattern.
A variable without replaces contributes no renames; it exists to be
interpolated. One that is neither used as a token nor referenced by any
substitution warns as MANIFEST_UNUSED_VARIABLE.
A variable can constrain its value: pattern is a regular expression the
whole value must match (it is anchored for you), and choices lists the
only values allowed — the prompt shows them. An answer the variable refuses
— outside choices, not matching pattern, not a name its casings can be
derived from — is explained and asked again, up to three times, rather than
failing the unpack after every other prompt.
variables:
org:
pattern: '[a-z]+(\.[a-z]+)+'
default: com.example
platform:
choices: [android, ios]
default: android
A value outside them is VARIABLE_PATTERN_MISMATCH or VARIABLE_NOT_A_CHOICE;
a pattern that does not compile, or a default its own constraints reject, is
caught at pack.
Two variables whose tokens derive the same casing (my_app and my-app
share MyApp) warn as MANIFEST_CONFLICTING_TOKENS: those sites take the
later variable's value.
Resolution precedence: explicit --var › --vars-file › manifest default ›
interactive prompt. End of input is not an answer — with stdin closed, a
variable with no default stays unresolved and is reported as
VARIABLE_MISSING, rather than silently becoming an empty string; input that
ends after a refused answer keeps that answer, to fail on it, rather than
falling back to the default.
Substitutions
For strings the four casings cannot reach. from is always a literal, so a
rule stays greppable against the real project; to is a template.
extra_substitutions:
- from: https://api.super.dev
to: https://api.example.com
- from: com.example.superServer
to: com.example.{{ project_name | camelCase }}
Content only — these never touch paths.
Path renames
The mirror image: paths only, never content.
path_renames:
- from: kotlin/com/example/super_server
to: kotlin/com/example/{{ project_name }}
This exists for the case a replaces token structurally cannot express: when
one literal needs opposite treatment in different places. In a Flutter
project android/app/ is a hard-coded Gradle module path that must survive,
while kotlin/com/example/app/ is the package and must move.
Matching is longest-first, so an entry mapping a path to itself pins it against a shorter rename:
path_renames:
- from: android/app/ # 12 chars — beats the "app" rename key
to: android/app/ # pinned to itself
- from: kotlin/com/example/app
to: kotlin/com/example/{{ project_name }}
Verbatim files
no_substitute: # text files copied byte-for-byte…
- pubspec.lock # …though their PATH is still renamed
- "**/*.g.dart"
binary_extensions: # on top of the built-in set
- myblob # (png, jpg, ttf, zip, sqlite, …)
A file whose extension says text but whose bytes are not valid UTF-8 is also copied verbatim rather than failing the unpack.
Message
What to tell the person once the project exists — the next steps, usually:
message: |
Next:
cd {{ project_name }}
dart pub get
dart run bin/{{ project_name | snakeCase }}.dart
mold unpack prints it, rendered, after Unpacked into … — not on a dry
run. It is a template like a substitution's to: and is validated like one;
a variable it names counts as used. --json carries it as message, and
UnpackPlan.message holds it rendered.
Interpolation and transforms
Only the to: side of a substitution or path rename is a template, and the
message.
{{ variable }} the resolved value, unchanged
{{ variable | transform }} the value through one transform
{{{{ a literal "{{"
| transform | value | becomes |
|---|---|---|
snakeCase |
my_project |
my_project |
kebabCase |
my_project |
my-project |
camelCase |
my_project |
myProject |
pascalCase |
my_project |
MyProject |
screamingCase |
my_project |
MY_PROJECT |
titleCase |
my_project |
My Project |
flatCase |
my_project |
myproject |
pathCase |
com.acme |
com/acme |
yamlString |
C:\work |
"C:\\work" |
Whitespace inside the braces is insignificant. Chaining ({{ a | x | y }}) is
refused rather than read as a transform named x | y.
Why this exists. Flutter derives its Apple bundle identifier by camelCasing
the project name while Android keeps it snake, so one name yields
com.example.myProject and com.example.my_project in the same project —
plus My Project for the home-screen label. The four casings cover one of the
three; named transforms cover the rest.
The Android sources are the same fact one level up: they live in
android/app/src/main/kotlin/com/example/…, a directory path spelling out the
package the organisation names. pathCase derives that path from the
organisation itself — com.acme → com/acme — so the two cannot be set
inconsistently. It is the one transform that is not a case conversion: segments
keep the spelling they were given, so com.acmeCorp is com/acmeCorp, not
com/acme/corp.
yamlString is for a value that lands in YAML: it writes the value as a whole
scalar, so it goes where a scalar goes — root: {{ dir | yamlString }}, not
inside quotes of the template's own. A value that reads back as itself stays
plain (my_app, lib/src); anything else — a backslash, a : , a leading
*, yes, 42, an empty value — is double-quoted with escapes, so the file
parses to the same string whatever was typed.
Templates are validated at pack time and again at unpack, so a bad placeholder or an undeclared variable fails when the template is built, not when someone uses it. Rendering happens at unpack, and a rendered value is never re-scanned — it cannot cascade into another substitution.
Precedence
All rules are applied in a single left-to-right pass:
- longest match wins at the same position; leftmost wins across positions
- no cascading — a substituted value is never re-scanned
- an explicit substitution beats a derived rename key on collision
- between two variables whose tokens collapse to the same casings, the later one in the manifest wins
Choosing a replaces token
Substitution is literal, so a token that is mostly a substring of other words
corrupts the project. mold measures this rather than guessing: it counts
every occurrence of every derived casing, and how many sit inside a longer
identifier.
| collateral | verdict |
|---|---|
| ≥ 30% | error — PROJECT_TOKEN_TOO_GENERIC |
| ≥ 5% | warning — PROJECT_PARTIAL_OVERLAP |
| below | silent |
A match is collateral when the characters around it continue the word in the
casing that matched. For the snake, kebab and SCREAMING forms a letter or
digit continues it, and _, - and anything else end it — so
app_template_test is a whole-word match for replaces: app_template, and
renaming it to my_app_test is the point. For the Pascal form only a following
lowercase letter continues it: AppTemplateTest and MyAppTemplate are whole
words, AppTemplateer is not. my_app in my_appendix, app in
application and MyApp in MyApplication are all still collateral.
Measured on a fresh flutter create project (Flutter 3.47): bin 75%, test
73%, app 65%; runner and the project's own name under 5%.
[PROJECT_TOKEN_TOO_GENERIC] Variable 'project_name': token 'app' matches 615
times; 398 (65%) inside longer words: application (72),
flutter_application_1 (40), apple (36). Substituting it would rewrite those
too — pick a more distinctive token, or replace these sites with explicit
extra_substitutions.
When a token is too generic, the way forward is explicit extra_substitutions
and path_renames, not a blunter rename. The check matches exactly as
unpacking will — one pattern over every casing and every explicit
substitution, longest first — so a site an extra_substitutions entry claims
is not counted against the token: pinning MyApplication to itself removes it
from the collateral.
The four casings are not every way a name gets written, and the check says
so. A token's words matched case-insensitively, joined by one of ._- or by
nothing, that land on no derived casing are reported as
PROJECT_UNCOVERED_CASING — with the entries that would rename them,
wherever a transform reproduces the spelling exactly: under
extra_substitutions for a spelling in content, under path_renames for one
in a path. On a fresh
flutter create project named app_template:
[PROJECT_UNCOVERED_CASING] Variable 'name': 'appTemplate' (10:
ios/Runner.xcodeproj/project.pbxproj, macos/Runner.xcodeproj/project.pbxproj,
macos/Runner/Configs/AppInfo.xcconfig), 'App Template' (1: ios/Runner/Info.plist)
spell token 'app_template' in a way no derived casing matches, so unpacking
leaves them as they are. To rename them too, add to extra_substitutions:
- from: "appTemplate"
to: "{{ name | camelCase }}"
- from: "App Template"
to: "{{ name | titleCase }}"
That is the iOS and macOS bundle identifier and the display name: without it, the scaffolded app ships under the template's identity.
The same pass reports other things worth knowing at pack:
- a token that occurs in files copied verbatim (binary extensions,
no_substitute, non-UTF-8) —PROJECT_TOKEN_IN_VERBATIM; their paths are renamed, their content keeps the token; - an
extra_substitutionsorpath_renamesentry that never fires —PROJECT_SUBSTITUTION_UNUSED,PROJECT_PATH_RENAME_UNUSED; usually a typo; - a file that looks like a secret —
PROJECT_SECRET_SUSPECTED: by name (.env,key.properties,google-services.json,*.pem,*.jks, SSH keys, …;.env.exampleand friends excepted) or by content (a PEM private key, an AWS access key id, a Google API key, a GitHub token). A template is made to be shared; - a path that cannot exist everywhere —
PROJECT_PATH_NOT_PORTABLE: two that differ only in case (one file on macOS and Windows), or a name Windows refuses (aux.txt,a:b, a trailing dot). A warning, since the template unpacks fine elsewhere; unpacking it on Windows isRENAME_INVALID_NAME, before anything is written.
At unpack, a variable's value is checked against the template:
VARIABLE_VALUE_PRESENT when a casing of it already occurs there as a word.
Unpacking super_server as my_app into a template with a MyApp class
leaves two classes called MyApp, and nothing else would notice.
The manifest file itself is excluded from that search — a replaces: line is a
declaration, not evidence the project uses the token.
Previewing a rename
mold unpack super_server.mold -t ./my_project --dry-run
Dry run — nothing written to ./my_project.
129 files: 3 renamed, 18 rewritten (50 replacements), 108 unchanged.
Renamed:
android/.../com/example/super_server/MainActivity.kt -> .../my_project/... (1)
super_server.iml -> my_project.iml
Rewritten:
pubspec.yaml (1)
macos/Runner.xcodeproj/project.pbxproj (13)
Add --diff for the content changes as a unified diff. Unchanged files are
counted but not listed — a template is mostly unchanged files, and listing them
buries the ones that matter.
The preview runs the same validation, resolution and substitution rules as a real unpack, so it cannot disagree with one; it is that computation minus the writes. The target is validated too, so a dry run also answers "is the destination usable".
Into an occupied target (--force), the preview says what the write would do
to the files already there:
Dry run — nothing written to ./my_project.
4 files: 1 renamed, 3 rewritten (5 replacements), 1 unchanged.
Target is not empty: 1 to create, 3 to overwrite (2 identical).
...
Created:
lib/src/new_in_template.dart
Overwritten:
pubspec.yaml
Overwrites that would change nothing are counted, not listed. With --diff,
an overwritten file is diffed against the file already there (--- pubspec.yaml (in target)), since that is the change the write makes; a created file is
diffed against the template, as without --force.
mold pack --dry-run is a preflight: which files would be captured, how
many each filter removed, and the token verdict.
Dry run — nothing written. 129 files would be captured, 36 gitignored,
0 symlinks skipped.
mold pack --diff answers the other question a template author has — are my
rules right? — without packing, distributing and unpacking first:
mold pack ./super_server --diff --var project_name=tempo
Preview — nothing written.
129 files: 1 renamed, 18 rewritten (50 replacements), 111 unchanged.
...
--- linux/runner/my_application.cc
+++ linux/runner/my_application.cc
- gtk_window_set_title(window, "app");
+ gtk_window_set_title(window, "tempo");
The files are read and checked as a pack would, then handed to the same
planner unpack --diff uses, so the two cannot disagree — and an unpack's
warnings, such as a value already in the template, show up in the preview
too. Nothing is compressed. Values come from --var, --vars-file and
manifest defaults only — pack never prompts, so it stays usable from CI.
Validation
Every phase is validated with structured, coded errors. An error-severity
issue throws a ValidationException (the CLI exits 1); warnings never block,
and reach onWarning — on stderr in the CLI.
| Phase | Code | Meaning |
|---|---|---|
| pack | MANIFEST_MISSING_NAME |
required field absent |
| pack | MANIFEST_MISSING_VERSION |
required field absent |
| pack | MANIFEST_DUPLICATE_VARIABLE |
two variables share a name |
| pack | MANIFEST_INVALID_GLOB |
a bad pattern in include/exclude/no_substitute |
| pack | MANIFEST_EMPTY_REPLACES |
replaces: "" |
| pack | MANIFEST_UNSUPPORTED_REPLACES |
token has no ASCII word characters |
| pack | MANIFEST_MALFORMED_PLACEHOLDER |
bad syntax inside {{ }} |
| pack | MANIFEST_UNTERMINATED_PLACEHOLDER |
{{ with no }} |
| pack | MANIFEST_UNKNOWN_VARIABLE |
placeholder names an undeclared variable |
| pack | MANIFEST_UNKNOWN_TRANSFORM |
not one of the transforms listed above |
| pack | MANIFEST_UNUSED_VARIABLE ⚠ |
variable does nothing |
| pack | MANIFEST_DUPLICATE_SUBSTITUTION |
two entries in one section share a from |
| pack | MANIFEST_CONFLICTING_TOKENS ⚠ |
two variables derive the same casing |
| pack | MANIFEST_INVALID_PATTERN |
a variable's pattern does not compile |
| pack | MANIFEST_INVALID_DEFAULT |
a default its own constraints reject |
| pack | MANIFEST_UNKNOWN_KEY ⚠ |
a key the format does not define |
| both | MANIFEST_INVALID |
not UTF-8 YAML, or not shaped like a manifest |
| both | MANIFEST_NOT_FOUND |
no manifest file at the path |
| pack | PROJECT_DIR_NOT_FOUND |
source directory missing |
| pack | PROJECT_DIR_EMPTY |
nothing to pack after filtering |
| pack | PROJECT_REPLACES_NOT_FOUND |
token occurs in no captured file |
| pack | PROJECT_TOKEN_TOO_GENERIC |
≥30% of matches are collateral |
| pack | PROJECT_PARTIAL_OVERLAP ⚠ |
≥5% of matches are collateral |
| pack | PROJECT_SYMLINK_SKIPPED ⚠ |
a symlink was left out, with the reason |
| pack | PROJECT_TOKEN_IN_VERBATIM ⚠ |
a token in files copied verbatim |
| pack | PROJECT_SUBSTITUTION_UNUSED ⚠ |
an extra_substitutions entry never fires |
| pack | PROJECT_PATH_RENAME_UNUSED ⚠ |
a path_renames entry never fires |
| pack | PROJECT_UNCOVERED_CASING ⚠ |
a spelling of a token no casing matches |
| pack | PROJECT_SECRET_SUSPECTED ⚠ |
a file that looks like a secret |
| pack | PROJECT_PATH_NOT_PORTABLE ⚠ |
a path not every platform can create |
| unpack | ARCHIVE_NOT_FOUND |
no archive file at the path |
| unpack | ARCHIVE_INVALID |
not a valid gzipped tar |
| unpack | ARCHIVE_TOO_LARGE |
decompresses past the size ceiling |
| unpack | ARCHIVE_MISSING_MANIFEST |
no embedded mold.yaml |
| unpack | ARCHIVE_MISSING_FILES |
no files/ tree |
| unpack | ARCHIVE_UNSAFE_PATH |
an entry escapes the target directory |
| unpack | TARGET_NOT_DIRECTORY |
the target, or a parent of it, is a file |
| unpack | TARGET_OCCUPIED |
target is not empty (no --force) |
| unpack | TARGET_NOT_WRITABLE |
no write permission |
| unpack | VARIABLE_MISSING |
required variable has no value |
| unpack | VARIABLE_INVALID_FORMAT |
value is not a well-formed name token |
| unpack | VARIABLE_UNKNOWN |
--var names a variable the template lacks |
| unpack | VARIABLE_PATTERN_MISMATCH |
value does not match the variable's pattern |
| unpack | VARIABLE_NOT_A_CHOICE |
value is not one of the variable's choices |
| unpack | VARIABLE_VALUE_PRESENT ⚠ |
the value already occurs in the template |
| unpack | VARIABLE_DROPPED ⚠ |
a stamped value names a variable now gone |
| unpack | RENAME_COLLISION |
two entries on one path, or a file under one |
| unpack | TARGET_PATH_CONFLICT |
--force: a path is blocked in the target |
| unpack | RENAME_UNSAFE_PATH |
a renamed path escapes the target |
| unpack | RENAME_CASE_COLLISION ⚠ |
renamed paths differ only in case |
| unpack | RENAME_INVALID_NAME |
on Windows: a path Windows cannot create |
| unpack | UNPACK_STALE_FILES ⚠ |
files the template dropped, left in place |
| unpack | UNPACK_STAMP_INVALID ⚠ |
the target's .mold.json is not a stamp |
| unpack | UNPACK_STAMP_FOREIGN ⚠ |
the target's stamp is another template's |
| unpack | UNPACK_OVERWRITES_EDITS ⚠ |
overwriting files edited since the stamp |
| unpack | UNPACK_CHMOD_FAILED ⚠ |
files written, but not made executable |
| unpack | UNPACK_UNDO_INCOMPLETE ⚠ |
a failed write could not be fully undone |
⚠ = warning; does not block. Every code is a constant on MoldCode.
Every warning is reported at both pack and unpack. A template is usually unpacked by someone other than its author, so a warning that only reached whoever packed it reached nobody.
Phase order — pack: Manifest → Project; unpack:
Archive → Manifest → Target → Variables → Rename. The first failing phase aborts
before the next runs. Target precedes variables so an interactive unpack does
not make you answer every prompt before learning the destination is occupied.
TARGET_PATH_CONFLICT is the one target check that runs after Rename: which
paths conflict depends on where renaming puts each file.
Exit codes
| code | meaning |
|---|---|
0 |
success |
1 |
validation, IO or format failure |
64 |
usage error (bad arguments) |
Library usage
import 'package:mold/mold.dart';
// Pack — always returns the gzipped-tar archive bytes.
final archive = await const Bundler().bundle(
projectDir: './super_server',
manifest: Manifest.fromFile('./super_server/mold.yaml'),
onWarning: (warning) => print('Warning: $warning'),
);
await File('super_server.mold').writeAsBytes(archive);
// Unpack from bytes (e.g. an embedded template)…
await const Unbundler().unbundle(
bytes: archive,
targetDir: './my_project',
vars: {'project_name': 'my_project'},
);
// …or from a file.
await const Unbundler().unbundleFile(
source: 'super_server.mold',
targetDir: './my_project',
vars: {'project_name': 'my_project'},
);
onWarning receives each non-fatal problem as a ValidationError — a skipped
symlink, a token that will over-reach, a failed chmod — with its code, so
a caller can filter or escalate by code rather than by message.
Both unbundle options of the CLI are parameters: overwrite: true is
--force, stamp: true is --stamp.
Errors
Every problem with the inputs — the manifest, the project, the archive, the
variables, the target — throws a ValidationException; its errors carry
stable codes, all listed as constants on MoldCode. A failing filesystem
throws an IOException. Nothing else is expected: a StateError is a bug.
try {
await const Unbundler().unbundle(bytes: archive, targetDir: dir);
} on ValidationException catch (e) {
if (e.codes.contains(MoldCode.targetOccupied)) {
// ask, then retry with overwrite: true
}
}
The async methods report through their Future: nothing is thrown before it
is returned.
Plan, show, apply
An unpack is two steps, and both are public. plan decides everything —
validation, variables, where each file lands and the exact bytes it gets —
and apply writes a plan. unbundle is the two in a row, so a preview and
the write are the same computation, and a caller that shows a preview and
asks first applies the plan it showed instead of computing it again:
const unbundler = Unbundler();
final plan = unbundler.plan(
bytes: archive,
targetDir: './my_project',
vars: {'project_name': 'my_project'},
overwrite: true,
);
for (final file in plan.overwritten.where((f) => !f.identicalToExisting)) {
print(const UnifiedDiff().render(
before: file.existing ?? '',
after: file.after ?? '',
fromLabel: '${file.to} (current)',
toLabel: file.to,
));
}
if (confirmed) {
await unbundler.apply(plan); // validates the target again first
}
Each PlannedFile carries from, to, verbatim, replacements, the
bytes it will be written with, executable, and — with withContent (the
default for plan) — the text before and after substitution. With
overwrite: true it also says whether it overwrites a file already in the
target, whether that file is identicalToExisting, and its existing text.
plan.created, plan.overwritten and plan.stale split the files; a plan
also carries the manifest and the resolved values. A plan made with no
targetDir previews substitution only and cannot be applied.
To change a file's bytes beyond what the manifest substitutes, pass rewrite.
It is called while planning, once per template file, with where the file lands
and what substitution made of it, and returns the bytes to write — so
identicalToExisting, the stamp's digests and everything else in the plan are
about those bytes. A file it changed is rewritten:
final plan = unbundler.plan(
bytes: archive,
targetDir: './my_project',
vars: {'project_name': 'my_project'},
rewrite: (path, bytes) =>
path == 'LICENSE' ? utf8.encode(licenseText) : bytes,
);
To regenerate a project from its stamp, pass the stamp's values as
recorded. They come after vars and before the resolver, and one naming a
variable the template has dropped is a VARIABLE_DROPPED warning, not the
VARIABLE_UNKNOWN error it would be in vars:
final stampFile = File('./my_project/${UnpackStamp.fileName}');
final stamp = stampFile.existsSync()
? UnpackStamp.tryParse(stampFile.readAsStringSync())
: null;
final plan = unbundler.plan(
bytes: archive,
targetDir: './my_project',
recorded: stamp?.vars ?? const {},
overwrite: true,
);
inspect describes an archive without unpacking it — its manifest (and
manifestYaml, as written), each file's size, which are executable.
Prompting
final resolver = VariableResolver(
noPrompt: false, // true → defaults only, never prompt
prompter: VariablePrompter(stdout, stdin.readLineSync),
);
await const Unbundler().unbundle(
bytes: archive,
targetDir: './my_project',
resolver: resolver,
);
A variable left without a value is VARIABLE_MISSING.
Embedding a template in a binary
-f bytes / -f base64 emit a Dart source file (with an
AUTO-GENERATED — DO NOT EDIT header) so a template can ship inside a compiled
executable and scaffold offline.
The file is meant to be committed into your package, so it is made to pass
your tooling unchanged. It carries // ignore_for_file: type=lint, the standard
header of generated code: lint sets disagree about its very declarations
(omit_obvious_property_types against always_specify_types), so no spelling
of them would satisfy every set, and errors are still reported. And
dart format leaves it as it is, in both the tall and the pre-3.7 style — no
need to exclude it from a format check.
mold pack ./super_server -f bytes -o lib/template.dart
mold pack ./super_server -f base64 -o lib/template.dart
// lib/template.dart (generated)
// AUTO-GENERATED — DO NOT EDIT.
// Produced by `mold pack`.
// ignore_for_file: type=lint
const List<int> kSuperServerTemplate = <int>[
// 2741 bytes: a gzipped tar archive.
31, 139, 8, 0, 0, 0, 0, 0, 0, 3, 237, 189, 107, 115, 219,
// ...
];
import 'package:mold/mold.dart';
import 'template.dart';
await const Unbundler().unbundle(
bytes: kSuperServerTemplate,
targetDir: './my_project',
vars: {'project_name': 'my_project'},
);
-n names both the output file and the generated const, so two templates
packed from one manifest do not collide on the identifier.
To render the source yourself:
final dartSource = const EmbedSource()
.bytesSource(archive: archive, name: 'super_server');
bytesSource and base64Source take a doc, written above the constant as
its /// comment.
What is preserved
- The owner-executable bit — a template's scripts and hooks stay runnable.
- A leading UTF-8 BOM — Windows-authored
.bat,.ps1and.csprojkeep their encoding marker. - Bytes of anything not substituted — binaries,
no_substitutematches, and any file that is not valid UTF-8, including CRLF, NUL bytes and emoji.
The archive is materialized in memory — no scratch directory is written to
disk, and pack never mutates the source. Unpacking keeps each file as a view
into the decompressed archive, and copies only the files substitution changes.
On a 48 MB, 1731-file corpus: pack ~2.2 s (half of it the portable deflate;
--fast or --level trade it), unpack ~0.4 s, peak memory ~300–360 MB.
Out of scope
- Templating the packed files. Project content is never evaluated — no
conditionals, loops, partials or includes, and a
{{ }}inside a packed file is just text.{{ }}is interpreted only in the manifest: ato:value and themessage. - Content-based binary detection — extensions only, no MIME sniffing. The UTF-8 fallback is a decode failure, not classification.
- File modes beyond the executable bit — no ownership, no full mode round trip.
- Preserving symlinks as symlinks — they are dereferenced, so an unpack needs no symlink privileges (Windows requires them).
- Empty directories — not represented in the archive.
- Compression other than gzip, encryption, or signing.
- Remote template registries or fetching over the network.
- Merging template updates into an edited project.
--forceregenerates over it and the stamp says what the template dropped, but a file the template has is replaced, not merged: edits in it are overwritten.
Libraries
- mold
mold— pack a project into a portable template archive and unpack it with manifest-driven renaming.