Skip to main content

mold 0.3.0 copy "mold: ^0.3.0" to clipboard
mold: ^0.3.0 copied to clipboard

Pack an existing project into a portable template archive and unpack it with manifest-driven renaming. Ships a `mold` CLI and is consumable as a library.

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 #


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 --force no 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_substitutions or path_renames entry 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.example and 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 is RENAME_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, .ps1 and .csproj keep their encoding marker.
  • Bytes of anything not substituted — binaries, no_substitute matches, 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: a to: value and the message.
  • 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. --force regenerates 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.
1
likes
160
points
107
downloads

Documentation

API reference

Publisher

verified publisherpro100.dev

Weekly Downloads

Pack an existing project into a portable template archive and unpack it with manifest-driven renaming. Ships a `mold` CLI and is consumable as a library.

Repository (GitHub)
View/report issues

Topics

#scaffolding #template #cli #boilerplate #project-generator

License

MIT (license)

Dependencies

archive, args, crypto, glob, path, yaml

More

Packages that depend on mold