File Provider Tutorial#
This tutorial walks you through using the file provider to read, write, check existence, delete files, and write multiple files at once with write-tree. You’ll learn how to use conflict strategies, backups, append with deduplication, and dry-run mode.
Prerequisites#
- scafctl installed and available in your PATH
- Basic familiarity with YAML syntax and solution files
Table of Contents#
- Reading Files
- Writing Files
- Checking File Existence
- Deleting Files
- Writing Multiple Files (write-tree)
- Conflict Strategies
- Append with Deduplication
- Backups
- Per-Entry Overrides in write-tree
- CLI Flags
- Dry-Run Mode
- Common Patterns
Reading Files#
The read operation reads the contents of a file. Create a file called read-file.yaml:
apiVersion: scafctl.io/v1
kind: Solution
metadata:
name: read-file-example
version: 1.0.0
spec:
resolvers:
config:
type: any
resolve:
with:
- provider: file
inputs:
operation: read
path: config.yamlRun it:
scafctl run resolver -f read-file.yaml -o json --hide-executionscafctl run resolver -f read-file.yaml -o json --hide-executionOutput:
{
"config": {
"content": "name: my-project\nversion: 1.0.0\n",
"path": "/abs/path/to/config.yaml"
}
}Writing Files#
The write operation creates or updates a file. Create a file called write-file.yaml:
apiVersion: scafctl.io/v1
kind: Solution
metadata:
name: write-file-example
version: 1.0.0
spec:
resolvers: {}
workflow:
actions:
create-readme:
provider: file
inputs:
operation: write
path: README.md
content: |
# My Project
Generated by scafctl.
createDirs: trueRun it:
scafctl run solution -f write-file.yaml --output-dir /tmp/demoscafctl run solution -f write-file.yaml --output-dir /tmp/demoThe output includes a status field showing what happened:
{
"success": true,
"path": "/tmp/demo/README.md",
"status": "created"
}Permissions#
Control file permissions with the permissions field (octal string):
inputs:
operation: write
path: deploy.sh
content: "#!/bin/bash\necho 'deploying...'"
permissions: "0755"Checking File Existence#
The exists operation checks whether a file exists:
spec:
resolvers:
has-config:
type: any
resolve:
with:
- provider: file
inputs:
operation: exists
path: .envOutput:
{
"has-config": {
"exists": true,
"path": "/abs/path/to/.env"
}
}Deleting Files#
The delete operation removes a file:
workflow:
actions:
cleanup:
provider: file
inputs:
operation: delete
path: tmp/build.logWriting Multiple Files (write-tree)#
The write-tree operation writes an array of file entries under a base path. This is the primary operation for scaffolding projects:
workflow:
actions:
scaffold:
provider: file
inputs:
operation: write-tree
basePath: .
entries:
- path: src/main.go
content: "package main\n\nfunc main() {}\n"
- path: go.mod
content: "module example.com/myapp\n\ngo 1.22\n"
- path: README.md
content: "# My App\n"Output includes per-file status and summary counts:
{
"success": true,
"operation": "write-tree",
"basePath": "/abs/output",
"filesWritten": 3,
"paths": ["src/main.go", "go.mod", "README.md"],
"filesStatus": [
{ "path": "src/main.go", "status": "created" },
{ "path": "go.mod", "status": "created" },
{ "path": "README.md", "status": "created" }
],
"created": 3,
"overwritten": 0,
"skipped": 0,
"unchanged": 0,
"appended": 0
}Note:
filesWrittencounts files actually written to disk (created + overwritten + appended), not the total number of entries.
Conflict Strategies#
The onConflict input controls what happens when a target file already exists. Five strategies are available:
| Strategy | Behavior | Use Case |
|---|---|---|
skip-unchanged | SHA256 compare; skip if identical, overwrite if different | Default. Idempotent re-generation |
overwrite | Always replace the file | Forced regeneration, CI pipelines |
skip | Never write if file exists | One-time scaffolding (initial setup) |
error | Return an error if file exists | Strict safety |
append | Append content to end of file; create if missing | Log files, .gitignore management |
When the file does not exist, all strategies create it.
Example: Skip Unchanged (default)#
workflow:
actions:
generate:
provider: file
inputs:
operation: write
path: config.yaml
content: "name: my-project\nversion: 1.0.0\n"
# onConflict defaults to skip-unchangedRunning this twice:
- First run:
status: "created" - Second run (same content):
status: "unchanged"— no disk write - After editing content in YAML:
status: "overwritten"
Example: Error on Existing#
workflow:
actions:
safe-write:
provider: file
inputs:
operation: write
path: important.txt
content: "do not overwrite"
onConflict: errorIf important.txt already exists, the action fails with: file already exists: /abs/path/to/important.txt
Example: Overwrite#
workflow:
actions:
force-write:
provider: file
inputs:
operation: write
path: generated.go
content: { rslvr: rendered-go }
onConflict: overwriteAppend with Deduplication#
The append strategy concatenates content to an existing file. Combined with dedupe: true, it performs line-level deduplication — only lines not already present in the file are appended.
Raw Append#
workflow:
actions:
append-log:
provider: file
inputs:
operation: write
path: build.log
content: "Build completed at 2026-03-16\n"
onConflict: appendAppend with Dedupe (.gitignore pattern)#
This is the recommended pattern for managing line-oriented config files like .gitignore:
workflow:
actions:
update-gitignore:
provider: file
inputs:
operation: write
path: .gitignore
content: |
dist/
build/
node_modules/
.env
onConflict: append
dedupe: trueIf .gitignore already contains dist/ and .env, only build/ and node_modules/ are appended. If all lines are already present, the status is unchanged.
Empty content is a no-op — the file is not modified and not created if missing. Status: unchanged.
Note:
dedupe: trueis only valid withonConflict: append. Using it with any other strategy returns a validation error.
Backups#
Set backup: true to create a .bak copy of existing files before mutating them. Backups apply to:
overwrite— always backs up before replacingskip-unchanged— backs up before overwriting when content differsappend— backs up before appending (not when unchanged)
workflow:
actions:
safe-overwrite:
provider: file
inputs:
operation: write
path: config.yaml
content: { rslvr: new-config }
onConflict: overwrite
backup: trueOutput includes the backup path:
{
"success": true,
"path": "/abs/output/config.yaml",
"status": "overwritten",
"backupPath": "/abs/output/config.yaml.bak"
}Backups use numbered naming: .bak, .bak.1, .bak.2, etc. The cap is defined by DefaultMaxBackups in settings (default: 5). Exceeding the cap returns an error.
Backup copies preserve the original file’s permissions.
Per-Entry Overrides in write-tree#
In write-tree, each entry can override the invocation-level onConflict, backup, and dedupe settings:
workflow:
actions:
write-project:
provider: file
inputs:
operation: write-tree
basePath: .
onConflict: skip-unchanged # default for all entries
entries:
- path: src/main.go
content: { rslvr: mainGoContent }
# Inherits skip-unchanged from invocation level
- path: .gitignore
content: "dist/\nbuild/\nnode_modules/\n"
onConflict: append
dedupe: true
# Appends unique lines only
- path: LICENSE
content: { rslvr: licenseContent }
onConflict: skip
# Never overwrite an existing LICENSE
- path: generated/api.go
content: { rslvr: apiGoContent }
onConflict: overwrite
backup: true
# Always regenerate, keep .bak of previous versionStrategy Precedence#
The conflict strategy resolves from most specific to least specific:
per-entry > per-invocation > CLI flag (--on-conflict) > default (skip-unchanged)CLI Flags#
Two CLI flags control conflict behavior across an entire solution run:
--on-conflict#
Sets the default conflict strategy for all file provider actions:
# Error if any generated file already exists
scafctl run solution -f solution.yaml --output-dir ./out --on-conflict error
# Force overwrite everything
scafctl run solution -f solution.yaml --output-dir ./out --on-conflict overwrite
# Skip all existing files (one-time scaffolding)
scafctl run solution -f solution.yaml --output-dir ./out --on-conflict skip# Error if any generated file already exists
scafctl run solution -f solution.yaml --output-dir ./out --on-conflict error
# Force overwrite everything
scafctl run solution -f solution.yaml --output-dir ./out --on-conflict overwrite
# Skip all existing files (one-time scaffolding)
scafctl run solution -f solution.yaml --output-dir ./out --on-conflict skipThe --on-conflict flag is the lowest-priority override. Provider-level and entry-level onConflict settings always take precedence.
--backup#
Creates .bak backups for all mutating file operations:
scafctl run solution -f solution.yaml --output-dir ./out --backupscafctl run solution -f solution.yaml --output-dir ./out --backupBoth flags work with run solution and run provider:
scafctl run provider file operation=write path=config.yaml content="new" --on-conflict overwrite --backupscafctl run provider file operation=write path=config.yaml content="new" --on-conflict overwrite --backupDry-Run Mode#
Use --dry-run to see what would happen without writing any files:
scafctl run solution -f solution.yaml --output-dir ./out --dry-run -o jsonscafctl run solution -f solution.yaml --output-dir ./out --dry-run -o jsonDry-run output includes planned statuses:
{
"_dryRun": true,
"_plannedStatus": "overwritten",
"_strategy": "skip-unchanged",
"path": "/abs/output/config.yaml"
}For write-tree, each entry reports its planned status based on the current filesystem state and effective strategy. The skip-unchanged strategy performs a full SHA256 comparison during dry-run to give accurate predictions.
Common Patterns#
Idempotent Project Generation#
Run a solution repeatedly during development — unchanged files are skipped, modified templates are overwritten:
workflow:
actions:
generate:
provider: file
inputs:
operation: write-tree
basePath: .
entries: { rslvr: rendered-templates }
# Default skip-unchanged: safe to re-runCI Pipeline with Force Overwrite and Backup#
workflow:
actions:
ci-generate:
provider: file
inputs:
operation: write-tree
basePath: ./generated
entries: { rslvr: rendered }
onConflict: overwrite
backup: trueOr via CLI flags:
scafctl run solution -f solution.yaml --output-dir ./generated --on-conflict overwrite --backupscafctl run solution -f solution.yaml --output-dir ./generated --on-conflict overwrite --backupOne-Time Scaffolding (Never Overwrite)#
Initial project setup that respects user edits:
workflow:
actions:
scaffold:
provider: file
inputs:
operation: write-tree
basePath: .
entries: { rslvr: scaffold-files }
onConflict: skipMixed Config File Management#
Combine strategies for different file types in one action:
workflow:
actions:
setup-project:
provider: file
inputs:
operation: write-tree
basePath: .
entries:
- path: src/main.go
content: { rslvr: main-go }
# Default: skip-unchanged
- path: .gitignore
content: "dist/\nbuild/\n.env\n"
onConflict: append
dedupe: true
- path: .editorconfig
content: { rslvr: editorconfig }
onConflict: skip
# Keep user's editorconfig if they have one
- path: Makefile
content: { rslvr: makefile }
onConflict: overwrite
backup: true
# Always update Makefile but keep backupStrict Safety Check#
Fail the entire solution if any output file would be overwritten:
scafctl run solution -f solution.yaml --output-dir ./out --on-conflict errorscafctl run solution -f solution.yaml --output-dir ./out --on-conflict errorFor write-tree, the error strategy collects all conflicts and reports them in a single error. Use failFast: true to stop at the first conflict:
inputs:
operation: write-tree
basePath: .
entries: { rslvr: files }
onConflict: error
failFast: trueNext Steps#
- Directory Provider Tutorial — List, create, copy, and remove directories
- Dry-Run Tutorial — Preview solution changes without side effects
- Provider Reference — Complete file provider input/output schema
- File Conflict Strategies Design — Detailed design rationale