Skip to content
yeet-srcPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

17 Commits

Folders and files

Repository files navigation

sc

Scaffolding engine. Point it at a template, give it a few inputs, and it renders a project. Point it at the same project later and it merges in what the template changed since, without losing your edits.

sc fork gh:yeet-src/templates --subdir rust-cli ./my-app -v name="My App"

sc never writes before showing you a plan, and the plan tells you exactly what each file will do: create, modify, merge, keep, or conflict.

The one rule. A project is render(template @ version, inputs) plus your edits on top. The inputs live in .sc.toml, and nothing else is state. Every command either looks (plan, tree, diff, where, status, test), changes the inputs or the template version (rederive). Files you never touched follow the template, including going away when an input stops producing them; files you edited are yours.

New here? Read the tutorial.

Install

Download a static binary for macOS or Linux (x86_64 or arm64) from the latest release, or build from source:

cargo install --git https://github.com/yeet-src/sc

Git templates need git and tar on the path.

Commands

Six verbs. Three write, three look.

Command What it does
sc plan [tpl] [dir] -v k=v What would happen. For a template: the files it makes. Inside a project: what rederive would do, and what you've edited. With -- -v k=v after it: what differs between two input sets.
sc fork <tpl> <dir> -v k=v Make a project. It remembers its template and inputs in .sc.toml. --watch keeps it re-rendered while you edit the template.
sc rederive [k=v...] [--ref v] Recompute a project from its template: newer version, new inputs, or both. Your edits are kept.
sc new <dir> Create a starter template.
sc test <tpl> Run a template's golden tests.
sc where [path] What a template becomes: with no path, its inputs and output tree; with a path, where that entry ends up and when.

Older names still work as hidden aliases: apply, update, pull, set, status, tree, diff, dev.

A template is a directory, gh:owner/repo, gl:owner/repo, or any git URL. Add #ref for a branch, tag or commit, and --subdir when the template lives inside a larger repository. Git templates are cached under ~/.cache/sc; --offline uses the cache without contacting the remote.

Inputs are given with -v name=value, repeatable. Anything the template declares and you do not pass must have a default, or the command stops and tells you what is missing.

Reading a plan

Template  rust-cli  A minimal Rust command-line application
Source    examples/rust-cli
Dest      ./my-app

Inputs
  name    = "My App"  (flag)
  edition = "2024"    (default)
  ci      = true      (default)

+ .github/workflows/ci.yml  (144 B, rendered)
> Cargo.toml  (109 B, rendered, merge: toml)
- README.md  (29 B, rendered)  kept by `README.md`
= src/main.rs  (52 B, rendered)

Plan: 1 to create, 1 to merge, 1 kept, 1 unchanged. 253 B to write.
Marker Meaning
+ Nothing exists there; the file will be created.
~ A file exists with different content; it will be replaced.
> A file exists and a merge rule combines the two.
* rederive three-way merged your edits with the template's changes. Red means conflict markers.
= Already identical. Nothing happens.
- A keep rule says leave it alone.
! Something incompatible is there, like a directory where a file goes.

--diff shows the hunks for anything marked ~, > or *. --contents prints every rendered file. --verbose lists what the template dropped and why. --json gives the whole plan as data.

Existing files

By default fork refuses to run if any file would be replaced or conflicts. --existing skip leaves those files alone and writes the rest. --existing overwrite replaces them. Conflicts, where a directory sits where a file goes, are never resolved automatically.

Writes are staged and then renamed into place, so a failure part-way leaves the destination as it was. If a file changed between plan and apply, sc notices and stops.

Rederiving

Every fork writes .sc.toml into the project recording the template, its commit if it came from git, and every input. rederive reads it and recomputes the project, then replays your edits on top:

sc rederive                          # newest template version, same inputs
sc rederive --ref v5                 # a specific version
sc rederive ci=false                 # same version, one input changed
sc rederive --case minimal           # same version, a golden case's whole input set
sc plan                            # template, version, inputs, and local edits

For git templates the previous commit is rendered as a common ancestor, so for every file:

  • never touched by you: takes the new version, or goes away if the template no longer produces it;
  • unchanged in the template: keeps your edits;
  • changed by both: merged line by line, with conflict markers where hunks overlap. Conflicted files are refused unless you pass --existing overwrite.

The plan shows removals as x and says how many files carry local edits. --plan-only shows without writing. Pin templates to a tag or commit (gh:org/repo#v2) if you want the version to move only when you bump --ref. pull (always newest) and set (always the recorded version) remain as aliases.

Writing a template

sc new my-template writes a starter you can edit, and sc where my-template shows what any template asks for and how each of its files will be treated. The pieces:

my-template/
  sc.toml                   manifest (optional)
  .sc/                      partials, hook scripts, tests; never emitted
  README.md                 {{ name }} inside files is rendered
  src/{{ name | snake_case }}.rs      and inside paths
  .gitignore                a real dotfile; nothing special
  .github/workflows/ci.yml  present only when [tree] says so, see below
  LICENSE@license/MIT       one of these becomes LICENSE, picked by the license input
  LICENSE@license/Apache-2.0

Templates use minijinja, a Jinja2 implementation. Every input is a variable. Filters snake_case, kebab_case, pascal_case, camel_case, shouty_snake_case and title_case are added to the built-in set. An undefined variable is an error, never silent empty text. {% include "partials/header.md" %} resolves against .sc/.

A directory with no manifest is a valid template. Every -v is then a string.

Or watch it live. sc fork my-template ./scratch -v name=Demo --watch renders into ./scratch and re-renders whenever the template changes. The scratch tree's .sc.toml is the switchboard: set ci = false in it and the .github/ directory disappears. Edits inside the scratch tree are overwritten, so treat it as a view.

Shell setup, optional. Two lines in your rc give you completions and a line above the prompt saying what the current directory becomes, empty outside a template:

source <(sc completions zsh)
_sc_prompt() { SC_WHERE="$(sc where --prompt zsh 2>/dev/null)"; [ -n "$SC_WHERE" ] && SC_WHERE="$SC_WHERE"$'\n'; }
precmd_functions+=(_sc_prompt); setopt PROMPT_SUBST; PROMPT='${SC_WHERE}'"$PROMPT"
rust-cli › .github/workflows/  when ci
~/my-template/.github/workflows %

The manifest

[template]
name = "rust-cli"
description = "A minimal Rust command-line application"

[vars.name]                       # asked for in this order
prompt = "Project name"
pattern = "[A-Za-z][A-Za-z0-9 _-]*"

[vars.edition]
type = "choice"
choices = ["2021", "2024"]
default = "2024"

[vars.ci]
type = "bool"
default = true

[vars.services]                   # lists are JSON on the command line
type = "list"
default = [{ name = "api", port = 8080 }]

Variable types: string (default), bool, int, choice, list.

The tree section

Per-path rules live in one table keyed by output path. Globs are allowed, and a trailing slash covers a directory and everything under it.

[tree]
".github/"     = { when = "ci" }                  # dropped when the condition is false
"Dockerfile"   = { when = "deploy == 'container'" }
"Makefile"     = { when = "not docker" }
"README.md"    = { keep = true }                  # created if absent, never touched again
"Cargo.toml"   = { merge = "toml" }               # deep merge, keeps your comments and layout
"package.json" = { merge = "json" }
"k8s/*.yaml"   = { merge = "yaml" }               # deep merge; comments are not preserved
".gitignore"   = { merge = "append-lines" }       # adds lines the file lacks
"src/lib.rs"   = { merge = "insert", marker = "// sc:modules" }
"assets/**"    = { raw = true }                   # copied byte for byte, paths still rendered

when is a template expression over the inputs, so anything {% if %} accepts works. sc where my-template -v ci=false shows which entries a set of inputs turns off, and sc plan my-template -v ci=false -- -v ci=true shows exactly what changes between two sets.

Variants

A directory named NAME@VAR/ holds several versions of NAME, one per value of input VAR. The child whose name (minus an alphabetic extension) equals the value is emitted as NAME; no match emits nothing. Children can be files or directories, and groups nest. There is nothing to declare and nothing to check out: the versions are ordinary files you can see and edit.

README.md@public/        public = true  -> README.md from true.md
  true.md                public = false -> nothing
Dockerfile@runtime/      runtime = "node" -> Dockerfile from node.Dockerfile
  node.Dockerfile
  python.Dockerfile
config@env/              env = "prod" -> config/app.yaml from prod/
  dev/app.yaml
  prod/app.yaml

Declare the selecting input as a choice so a misspelled value is rejected instead of silently emitting nothing; sc plan --verbose lists groups that matched nothing. sc where shows each group's children with the value they serve and marks the chosen one under -v. The @ is configurable as variant_sep under [files].

Other file rules

[files]
exclude = ["*.orig"]              # never emitted; matched against template paths
dot_prefix = "_"                  # off by default: _gitignore -> .gitignore, for
                                  # registries that strip dotfiles on publish

[files.rename]                    # explicit names, instead of or alongside _
"gitignore" = ".gitignore"
"github/" = ".github/"

[[files.syntax]]                  # for files that use {{ }} themselves
path = "charts/**"
variable = ["[[", "]]"]
block = ["[%", "%]"]
comment = ["[#", "#]"]

The older forms [files] keep, [files] raw and [files.merge] still work and mean the same as the [tree] fields.

Binary files are detected and copied as-is.

Hooks

[[hooks]]
name = "git init"
phase = "after"                   # after: in the destination, once files are written
when = "{{ git }}"                # skipped when this renders false, 0, no or empty
run = """
set -e
git init -q
"""

[[hooks]]
phase = "before"                  # before: in the template, before planning
script = "hooks/check.sh"         # under .sc/, run via its shebang

Hooks see the inputs as SC_VAR_<NAME> plus SC_TEMPLATE and SC_DEST. Their text is rendered as a template first (render = false to skip). The plan lists every hook before you confirm. --no-hooks skips them.

A before hook that prints a JSON object adds those keys as inputs.

Generated files and shell()

[[files.generate]]
path = "LICENSE"
run = "curl -s https://choosealicense.com/licenses/{{ license }}.txt"
once = true                       # only when the file does not exist yet

Inside any template, {{ shell("git config user.name") }} expands to the command's output.

Anything that runs before you confirm, meaning before hooks, generate rules and shell(), is refused unless you pass --allow-shell. The refusal lists the commands; the plan shows what ran under "Ran while planning".

Composing templates

Templates stack. A layer underneath contributes its inputs, files and hooks; the layer on top overrides files path by path, except where a merge rule on either side says to combine them instead. So a CI template with ".gitignore" = "append-lines" adds its lines to the project's .gitignore rather than replacing it.

From the command line, for any combination:

sc fork ./project ./my-app --with gh:org/ci-template#v2 -v name="My App"

--with is repeatable, asks for the union of all inputs, and is recorded in the answers file so sc rederive applies the same layers again.

In a manifest, for a fixed combination:

[[uses]]                          # layer this template over another
source = "gh:org/base#v3"         # files here override the base's, path by path

[[mounts]]                        # place a sub-template at a path, once per element
path = "services/{{ item.name }}"
template = "parts/service"        # a directory in this template, or a git spec
for = "services"                  # a list input; item and index are available
inputs = { name = "{{ item.name }}", port = "{{ item.port }}" }

A mounted template has its own manifest and rules. Its hooks run in its mount directory with its own inputs. Where the outer template and a mount produce the same path, the outer one wins unless a merge rule combines them.

Golden tests

.sc/tests/<case>/inputs.toml      the inputs for this case
.sc/tests/<case>/expected/        the output tree they must produce

sc test my-template --update writes the expected trees; sc test my-template compares against them and shows a diff for anything that changed. Commit the expected trees with the template.

Examples

  • examples/rust-cli: a single project with a conditional CI directory, merge and keep rules, and a hook.
  • examples/monorepo: list inputs and mounts, one crate per service.
sc plan examples/rust-cli -v name="Hello World" --contents
sc fork examples/monorepo ./shop -v name=Shop -v 'services=[{"name":"api","port":8080}]'

Releases and the commit convention

A push to master publishes a release with binaries for all four targets only when it contains a commit whose subject line carries a marker. Pushes without one publish nothing.

Marker Bump
[bump:patch] X.Y.Z+1
[bump:minor] X.Y+1.0
[bump:major] X+1.0.0

The highest level among the commits since the previous release wins, and the first release counts up from 0.0.0. Releases from a release/vX.Y maintenance branch never become "Latest". A pull-request check rejects misspelled markers. Cargo.toml carries a placeholder version; the computed one is stamped in at build time.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages