Normative specification for create-dig-app, the npm create dig-app scaffolder. This is the
authoritative contract: an independent reimplementation MUST behave as described here. Terms MUST,
MUST NOT, SHOULD, and MAY are used per RFC 2119.
create-dig-app is a zero-runtime-dependency Node.js CLI (Node ≥ 18, ESM). It copies a bundled
template file tree into a new project directory, substitutes __TOKEN__ placeholders, restores
_-prefixed dotfiles, and prints next steps. It also ships the canonical CHIP-0007 NFT
metadata/license tooling that the nft-collection template vendors verbatim. Scaffolding is a pure
filesystem operation: it MUST NOT mint, sign, spend, or contact the chain or network.
- Name / bin. The package name is
create-dig-app; it exposes one bin,create-dig-app, mapped tobin/create-dig-app.js.npm create dig-app/npm init dig-app/npx create-dig-appall resolve to this bin. - Module system. ESM (
"type": "module"). All source is ESM; there is no CJS entry point. - Runtime dependencies. ZERO. The package MUST run using only the Node standard library. The
only devDependency is the coverage runner (
c8). - Node engine.
node >= 18. The CLI MUST run on the Node 18 and 20 lines. - Published files. The npm tarball MUST include
bin,lib,templates,templates-ts,README.md, andLICENSE. Tests and coverage config are not shipped. - License. MIT.
create-dig-app [<name>] [options], where argv is everything after node <script>.
<name>— the first non-flag argument. Becomes the project directory name and the npm package name after normalization (§6.1). Additional positionals are ignored.
| Option | Alias(es) | Value | Effect |
|---|---|---|---|
--template |
-t |
<template> |
Select a template (canonical id or alias, §5). Also accepted as --template=<t>. |
--typescript |
--ts |
— | Request the TypeScript variant. |
--javascript |
--js |
— | Request the JavaScript variant (the default). |
--lang |
— | <js|ts> |
Same as the language flags; value normalized per §7.1. Also --lang=<v>. |
--json |
— | — | Machine mode: one structured object on stdout, all prose on stderr, no prompts. |
--list-templates |
— | — | Emit the template registry (as data with --json, else the help text). |
--help-json |
— | — | Emit the full machine self-description (flags + templates + exit codes) as JSON. |
--help |
-h |
— | Print human help. |
--version |
-v |
— | Print the version. |
-- |
— | — | The npm-init argument separator; ignored (a no-op token). |
- An unrecognized token beginning with
-MUST cause a parse error →USAGEexit (§4). --lang <value>and--lang=<value>MUST normalize the value (§7.1); an unrecognized value is a parse error →USAGE.- The last occurrence of a repeated option wins.
- When both a language flag and a positional appear in either order, both take effect.
- Parsing MUST NOT have side effects (no filesystem writes, no prompts).
When multiple action flags are present, exactly one action runs, in this order, and returns
SUCCESS:
--help-json(always to stdout, never gated by--json).--list-templates.--help.--version.
If none of the above is set, the tool proceeds to scaffold (§3).
- Parse argv (§2). On parse error, emit a
USAGEerror and returnUSAGE. - Handle the action flags in the §2.4 order; return if one fires.
- Resolve
appNameandtemplate:- If either is missing AND the process is interactive (
--jsonnot set AND both stdin and stdout are TTYs), prompt for the missing values (§8). - If either is missing AND the process is NOT interactive, emit a
MISSING_ARGSerror and returnMISSING_ARGS. Under--jsonthe tool MUST NOT prompt (agents run unattended); missing args fail closed.
- If either is missing AND the process is interactive (
- Validate the template early via
resolveTemplate(§5.4); an unknown template returnsUNKNOWN_TEMPLATE. - Resolve the language: if interactive and
--langwas not given, prompt (§8.3); otherwise use the requested language (defaultjs). - Compute
targetDir = resolve(cwd, normalizeAppName(appName))(§6.1). - Call
scaffold(§6). On error, classify it (§4.2) and return the mapped exit code. - On success:
- Under
--json: emit{ schemaVersion, ok: true, result }(§9.2) to stdout, returnSUCCESS. - Otherwise: print the success line, next steps (§6.4), and the free-until-publish notice to the
human sink; return
SUCCESS.
- Under
- Without
--json: human prose goes to stdout (thelogsink). - With
--json: all structured output (results, errors, self-descriptions) goes to stdout (theoutsink) and ALL human prose goes to stderr (theerrsink). A machine consumer MUST be able to parse stdout as JSON without stripping prose. --help-jsonoutput goes to stdout regardless of--json.
Each failure class has a distinct non-zero code so a caller can branch on the KIND of failure.
The table is stable and is also emitted by --help-json (§9.3).
| Code | Symbol | Meaning |
|---|---|---|
0 |
SUCCESS |
success |
1 |
INTERNAL |
unexpected/uncategorized internal error (generic fallback only) |
2 |
USAGE |
usage error (bad/unknown option or malformed arguments) |
3 |
UNKNOWN_TEMPLATE |
--template names a template that does not exist |
4 |
TARGET_NOT_EMPTY |
the target directory exists and is not empty (refusing to overwrite) |
5 |
MISSING_ARGS |
required args missing in non-interactive mode |
6 |
TEMPLATE_FILES_MISSING |
the bundled template files are missing (packaging bug) |
7 |
INVALID_APP_NAME |
the app name normalizes to nothing usable |
These numeric values MUST NOT change; new failure classes get new codes.
The --json error envelope carries a symbolic code string (UPPER_SNAKE) alongside the numeric
exit_code. The code MUST be derived from the error class, NEVER parsed from the human message.
Codes: BAD_USAGE (exit 2), UNKNOWN_TEMPLATE (3), TARGET_NOT_EMPTY (4), MISSING_ARGS (5),
TEMPLATE_FILES_MISSING (6), INVALID_APP_NAME (7), INTERNAL (1).
Errors thrown by the scaffolder are classified to { code, exit_code, message, hint, extra? }:
- An
UnknownTemplateError→UNKNOWN_TEMPLATE(exit 3);extra.templatecarries the requested id. - A message matching
exists and is not empty→TARGET_NOT_EMPTY(exit 4). - A message matching
template files for … are missing→TEMPLATE_FILES_MISSING(exit 6). - A message matching
is not a usable app nameorapp name must be a string→INVALID_APP_NAME(exit 7). - Anything else →
INTERNAL(exit 1).
Classification MUST match on the error type or stable substrings, never on incidental wording.
The registry is the SINGLE SOURCE OF TRUTH for what can be scaffolded. Each entry is
TemplateMeta:
| Field | Type | Meaning |
|---|---|---|
name |
string | Template id; equals the directory name under templates/<name>/. |
title |
string | Short human title for the picker. |
description |
string | One-line description for the picker and --help. |
outputDir |
string | Built-output dir digstore publishes; written into dig.toml (__OUTPUT_DIR__). |
buildCommand |
string | Build command digstore runs; written into dig.toml (__BUILD_COMMAND__). |
wallet |
boolean | True iff the template wires @dignetwork/dig-sdk (ChiaProvider). |
langs |
("js"|"ts")[] |
Languages offerable, in offer order. Always contains js; contains ts iff a variant exists under templates-ts/<name>/. |
| id | outputDir | buildCommand | wallet | langs |
|---|---|---|---|---|
static-site |
public |
npm run build |
false | js |
vite-react |
dist |
npm run build |
false | js, ts |
next-static |
out |
npm run build |
false | js, ts |
nft-drop |
dist |
npm run build |
true | js, ts |
nft-collection |
. |
npm run build |
true | js |
dapp-window-chia |
dist |
npm run build |
true | js, ts |
templateNames() MUST return the canonical ids in declaration (registry) order; aliases (§5.3)
MUST NOT appear.
- The JS variant of
<name>lives attemplates/<name>/. - The TypeScript variant of
<name>lives attemplates-ts/<name>/and MUST exist for every template whoselangsincludests. - A template directory MUST be self-contained and, after scaffolding,
npm install+ build (or, fornft-collection,npm run generate+npm run validate).
Hidden back-compat aliases map legacy ids to a canonical id. resolveTemplate MUST accept an alias;
templateNames, the picker, --help, and --list-templates MUST NOT list aliases. The registry
contains exactly one alias: static → static-site. Aliases exist only to keep old invocations
working and MUST be kept minimal.
canonicalTemplateName(name)returns the alias target ifnameis an alias, elsenameunchanged.resolveTemplate(name)canonicalizes then looks up the registry; a miss throwsUnknownTemplateError(requested)whose message lists the available canonical ids and whose.requestedfield carries the input.
scaffold({ appName, template, lang?, targetDir }) performs, in order:
- Validate before writing. Resolve
template(§5.4) andslug = normalizeAppName(appName)(§6.1) BEFORE touching the filesystem, so a bad invocation leaves the filesystem untouched. - Resolve language.
requestedLang = normalizeLang(lang)(defaultjs);resolvedLang = resolveLang(meta, requestedLang)=requestedLangif the template offers it, elsejs(§7.2). Atsrequest for a js-only template resolves tojs. - Guard the target.
targetDiris required. If it exists and is a non-empty directory, throw (exists and is not empty→TARGET_NOT_EMPTY). An empty/absent directory is allowed. - Locate the source.
templates-ts/<name>forts, elsetemplates/<name>. A missing source throws (template files for … are missing→TEMPLATE_FILES_MISSING). - Copy the tree recursively (binary assets copied byte-for-byte).
- Substitute placeholders in every non-binary file (§7.3, §7.4).
- Restore dotfiles (§6.2).
- Return the result object (§9.1).
If any step after directory creation throws, the partially-written targetDir MUST be removed
(rm -rf) before rethrowing — no half-written tree is left behind.
normalizeAppName(raw):
- MUST throw
app name must be a stringifrawis not a string. - Lowercase → trim → replace each run of non-
[a-z0-9]characters with a single-→ strip leading/trailing-. - If the result is empty, throw
"<raw>" is not a usable app name …(→INVALID_APP_NAME). - The result is the npm package name AND the target directory basename.
npm mangles some dotfiles on publish, so templates ship them _-prefixed and the scaffolder
restores the real name after copy+substitution. The mapping (applied only when the source file
exists):
| Shipped | Restored |
|---|---|
_gitignore |
.gitignore |
_npmrc |
.npmrc |
_env.example |
.env.example |
A file is treated as binary (skipped by substitution, copied verbatim) iff its lowercased extension
is one of: .png .jpg .jpeg .gif .ico .webp .woff .woff2 .wasm. All other files are text and are
run through substitution.
nextSteps({ slug, meta }) returns the printable step list:
cd <slug>npm install- Template-specific middle step:
nft-collection:npm run generatethennpm run validate.- Any other template with a build command EXCEPT
static-site:npm run dev. static-site: no dev step (its build only copiessrc/→public/).
digstore dev(free preview; no mint, no chain, no spend).digstore deploy(the only step that spends $DIG).
normalizeLang(raw): trim + lowercase; ts/typescript → "ts"; js/javascript → "js";
anything else throws Unknown --lang "<raw>" …. DEFAULT_LANG is "js".
resolveLang(meta, requested = "js") returns requested if meta.langs includes it, else "js".
A placeholder is __TOKEN__ where TOKEN matches [A-Z][A-Z0-9_]* (uppercase, digits, underscores;
starting with a letter), wrapped in double underscores. applySubstitutions(text, subs) replaces
every __TOKEN__ whose TOKEN is a key of subs; a TOKEN absent from subs is left VERBATIM (so a
typo surfaces as a leftover placeholder rather than silently blanking the file). A scaffolded tree
MUST contain no leftover known-token placeholders.
buildSubstitutions produces, for one scaffold:
| Token | Value |
|---|---|
__APP_NAME__ |
the normalized (npm-safe) slug |
__DISPLAY_NAME__ |
the original trimmed user-supplied name |
__SDK_VERSION__ |
the pinned @dignetwork/dig-sdk version specifier (§7.5) |
__OUTPUT_DIR__ |
meta.outputDir |
__BUILD_COMMAND__ |
meta.buildCommand |
SDK_VERSION is the specifier scaffolded wallet templates pin for @dignetwork/dig-sdk. While the
SDK is pre-1.0 it is the latest dist-tag (npm accepts a dist-tag wherever a version range is
allowed), so a freshly scaffolded app installs the newest stable SDK at scaffold time. It MUST be
bumped to a caret range (e.g. ^1.0.0) once the SDK cuts a stable release worth freezing to.
Prompts run only when NOT --json and both stdin and stdout are TTYs, and only for values not
supplied on the command line.
ask(rl, "Project name", "my-dig-app") — a free-text prompt with a default; an empty answer takes
the default.
askTemplate(rl) lists the canonical templates numbered 1..N. The user answers with a number
(default 1) OR by typing a canonical name. An out-of-range number or unrecognized name re-prompts.
Returns a canonical id.
askLang(rl, template) — skipped (returns "js") for a template whose langs lacks ts.
Otherwise offers 1) JavaScript (default) and 2) TypeScript, also accepting a typed
js/javascript/ts/typescript. An unrecognized answer re-prompts. Returns "js" or "ts".
SCHEMA_VERSION is 1. It MUST be bumped on any breaking change to the envelope shapes below.
Every machine envelope carries schemaVersion.
{
"appName": "my-app", // normalized slug
"template": "vite-react", // canonical id actually used
"lang": "ts", // language actually scaffolded ("js" | "ts")
"requestedLang": "ts", // language requested (may differ from lang on fallback)
"targetDir": "/abs/path/my-app",
"nextSteps": ["cd my-app", "npm install", "…"]
}lang reflects what was WRITTEN; when requestedLang === "ts" but lang === "js" (js-only
template) the human output notes the fallback and the JSON exposes both fields.
- Success (
--json):{ "schemaVersion": 1, "ok": true, "result": <§9.1> }on stdout. - Error (
--json):{ "schemaVersion": 1, "ok": false, "error": { "code", "exit_code", "message", "hint", …extra } }on stdout.codeis UPPER_SNAKE (§4.1);extracarries structured fields (e.g.templateforUNKNOWN_TEMPLATE) rather than flattening them into prose. --version --json:{ "schemaVersion": 1, "version": "<v>" }.--list-templates --json:{ "schemaVersion": 1, "templates": [<§9.4>] }.
An object describing the whole invocation contract: { schemaVersion, name, summary, flags[], templates[], exitCodes }, where exitCodes maps each numeric code to its human meaning (§4) and
templates is the §9.4 list. It is always printed to stdout and MUST be a superset sufficient for a
tool to introspect the CLI without scraping prose.
Each --list-templates / --help-json template entry is
{ id, title, description, outputDir, buildCommand, wallet, langs }, sourced directly from the
registry (no second source of truth).
Every scaffolded project MUST include:
dig.toml— the manifestdigstoreand the DIG SDK adapters read. It MUST carryoutput-dirandbuild-command(kebab-case; snake_case also accepted by digstore) and a defaultremote. Keys are the single source of truth fordigstore deployand the GitHub deploy Action. This is LOCAL config: writing it costs nothing.package.json— with the app name substituted; wallet templates depend on@dignetwork/dig-sdk(pinned per §7.5) and@walletconnect/sign-client(the SDK's optional WC peer dep, enabling the Sage fallback)..env.example(wallet templates) — declaringVITE_WALLETCONNECT_PROJECT_ID=(empty placeholder). The real id is never shipped; only the placeholder is tracked.README.md— the develop → preview (free) → publish flow for that template.- a real, buildable app whose build emits to the template's
outputDir(or, fornft-collection, a validated content root).
The TypeScript variant additionally ships tsconfig.json, .ts/.tsx sources, the typescript
(and React @types/*) devDeps, an env shim (vite-env.d.ts / next-env.d.ts), and a typecheck
script; it MUST type-check and build out of the box.
Wallet templates wire @dignetwork/dig-sdk's ChiaProvider. connect({ mode: "auto" }) MUST
prefer the injected wallet (window.chia, DIG Browser / extension) and fall back to
WalletConnect → Sage when no injected wallet is present, so a scaffolded dapp connects in an ordinary
browser. NFT spends MUST be built via the SDK's /spend builder (the canonical CHIP-0035 spend
constructor) and MUST NOT be hand-rolled. Nothing is minted, signed, or spent at scaffold time.
lib/nft-metadata.js is the SINGLE SOURCE OF TRUTH for the NFT tooling and is VENDORED
byte-identically into the nft-collection template's scripts/dig-nft.mjs, so a scaffolded project
runs the exact same dependency-free logic. The canonical off-chain JSON is a byte-for-byte mirror of
chip35_dl_coin (core/src/metadata.rs, collection.rs) and digstore
(digstore-chain/src/metadata.rs, collection.rs). Drift diverges the on-chain metadata_hash and
every verifying client rejects the NFT — the shape below is a HARD contract and MUST stay in
lock-step with those pinned test vectors.
A CHIP-0007 off-chain document has these keys, in this exact order, emitting only present fields:
format, name, description, sensitive_content, collection, attributes,
series_number, series_total, minting_tool
format— always the literal"CHIP-0007".name— required non-empty string.description— omitted when null/empty.sensitive_content— omitted when false; emitted astrueonly when explicitly true.collection—{ id, name, attributes? };attributesomitted when empty.attributes— array of{ trait_type, value }; omitted when empty. Order is PRESERVED (never sorted). Every value is stringified (numbers/booleans → strings) for byte-stable hashing; entries with an emptytrait_typeare dropped.traitTypeis accepted on input and normalized totrait_type.series_number— 1-based item position; omitted when absent.series_total— series size; omitted when absent.minting_tool— the tool tag; the DIG ecosystem stamps the literal"DIG"; omitted when absent/empty.
canonicalJson(md)= compactJSON.stringify(md)(no spaces, no key sort) — byte-identical toserde_json::to_string. BecausebuildChip0007Metadataalready orders and prunes the keys, no further canonicalization is applied.sha256Hex(bytes)= lowercase-hex SHA-256 (standard NIST SHA-256; byte-identical tochia_sha2::Sha256).metadata_hash=sha256(canonicalJson(md) as UTF-8 bytes).data_hash=sha256(raw resource bytes);license_hash=sha256(raw license-file bytes).
collectionId(name) = lowercase → each run of non-[a-z0-9] → single - → strip leading/trailing
-. Byte-identical to digstore's slug() and the hub's collectionId(), so collection.id
matches across the ecosystem.
mergeItem(collection, item, index, total) builds one item's metadata by merging the
collection-level ref ({ id, name, attributes }) + shared attributes with the item's own traits,
setting series_number = index + 1, series_total = total, and minting_tool = "DIG".
generateItemMetadata(collection, items) maps this over the whole collection, in order.
parseTraitsCsv(text) and parseTraitsJson(text) normalize a manifest into items
({ name, file?, description?, attributes[] }):
- Column/field roles (case-insensitive):
name/title→ name;file/filename/image/media/asset→ media file;description/desc→ description; every other column is a trait whosetrait_typeis the ORIGINAL column header. Empty cells emit no trait. An item with no name is an error. - CSV splitting honors RFC-4180-style double-quoted cells (commas inside quotes preserved;
""is an escaped quote). parseTraitsJsonaccepts an array, an{ items: [...] }envelope, or a single object; JSON attributes may usetrait_typeortraitType.itemsFromImages(fileNames)builds one item per image with the name humanized from the filename stem (dashes/underscores → spaces, title-cased), no traits.
LICENSES maps a license id to { title, spdx, deed?, text(holder, year) }. The defined ids are
cc0, cc-by-4.0, all-rights-reserved, commercial. licenseFileName(id) = LICENSE-<id>.txt.
licenseText(id, { holder?, year? }) renders the document; an unknown id throws listing the
available ids. The rendered text is a concise, accurate human summary plus the canonical SPDX id and
(for CC licenses) the upstream deed URL; the deed URL is authoritative for the full legal code.
capsuleResourceUris({ storeId, root?, resource }) returns { urn, https, uris } where:
urn=dig://urn:dig:chia:<storeId>[:<root>]/<resource>(leading slashes stripped fromresource).https=https://<storeId>.usercontent.dig.net/<resource>(the decrypted-capsule gateway, mirroring the hub'sCAPSULE_HTTPS_GATEWAY).uris=[urn, https]— thedig://URN first, then the https fallback (the order the hub and digstore use fordata_uris/metadata_uris/license_uris).
Until a capsule is published, generated URIs use the placeholder store id
STORE_ID_AFTER_PUBLISH; digstore fills the real id on publish.
validateMetadata(md, checks?) throws ValidationError on any failure. Schema checks: md is an
object; format === "CHIP-0007"; name is a non-empty string; attributes (if present) is an
array of { trait_type: string, value: string }; collection (if present) has string id and
name. Hash-agreement checks (each applied only when both sides are supplied): metadata_hash
equals metadataHashHex(md); media.data_hash equals sha256(media.data_bytes);
license.license_hash equals sha256(license.license_bytes) — mirroring digstore's
validate_uri_hash pre-mint gate.
The filesystem glue over the pure core, run against a real nft-collection project tree:
generateMetadata(root)— readscollection.json(derivingidfromnamewhen absent) and the sortedimages/art (extensions.png .jpg .jpeg .gif .webp .svg .avif), resolves items fromtraits.csv/traits.jsonif present (else one per image), writesmetadata/<stem>.json(each canonical JSON + trailing newline), computesdata_hashfrom the real image bytes andmetadata_hashfrom the canonical doc, folds in an already-generated license's URI+hash if present, and writesitems.json(the manifestdigstore collection mintconsumes). Errors on a missingimages/, no images, a missingname, or an item referencing a missing image.generateLicense(root)— reads thelicenseid fromcollection.jsonand writeslicenses/LICENSE-<id>.txt, returning its{ id, file, hash, path }. Errors when nolicensefield is set or the id is unknown.validateProject(root)— re-validates everymetadata/*.json(schema + canonical-form stability) and every manifest item'sdata_hash/metadata_hash/license_hashagainst the real bytes, returning{ ok: true, checked }; throws on the first disagreement or a missing/emptyitems.json.
- Free, offline, pure. Scaffolding and the NFT tooling MUST NOT mint, sign, spend, or contact the chain/network. All work is local filesystem I/O.
- Zero runtime deps. The CLI MUST run on the Node standard library alone.
- No half-written trees. A scaffold failure leaves the target directory removed (§6).
- Validate before write. Template + name validation happens before any filesystem write.
- Single source of truth. The template registry drives the picker,
--help,--list-templates,dig.tomlvalues, and the JSON self-description. The NFT core is the single source vendored into the template. - Byte-mirror discipline. The CHIP-0007 canonical shape (§11) MUST stay byte-identical to
chip35_dl_coinanddigstore; any change is spec-first and cross-repo (SYSTEM.md → Shared contracts → "CHIP-0007 NFT metadata"). - Stable machine contracts. Exit codes, error
codestrings, and the JSON envelope shapes are stable; breaking changes bumpSCHEMA_VERSION. - Two front doors, one set of templates.
create-dig-app(JS) anddigstore new(Rust) name the same starters by the same ids; the shared ids MUST agree.
- No secret material is shipped. Wallet templates ship only an empty
VITE_WALLETCONNECT_ PROJECT_ID=placeholder in.env.example; the real project id is provided by the user and never committed..gitignore(restored from_gitignore) excludes.env/.env.local. - No spends at scaffold time. Minting/deploying are explicit, wallet-signed actions the user triggers later; scaffolding never holds keys, signs, or broadcasts.
- Spend construction via the SDK only. Wallet templates build spends through the SDK's canonical
/spendbuilder, never hand-rolled — the on-chain spend contract stays in one audited place. - Deterministic, inspectable output. Substitution only replaces known
__TOKEN__placeholders; no template code is executed during scaffolding. A leftover placeholder is a detectable template bug, not a silent blanking.
- The test suite runs on the Node standard library (
node --test test/);npm run coverageruns it underc8with a CI-enforced floor: lines/branches/functions/statements ≥ 80 (thresholds in.c8rc.json, scoped tolib/+bin/,check-coverage: true). A build below the floor FAILS. - CI (
.github/workflows/ci.yml) runs the coverage gate across the Node 18 and 20 matrix on every push and PR, and additionally scaffoldsvite-react,dapp-window-chia, andnft-dropin the TypeScript variant and proves each installs, type-checks, and builds — verifying the emitted project (not just the file tree) is real.