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#

  1. Reading Files
  2. Writing Files
  3. Checking File Existence
  4. Deleting Files
  5. Writing Multiple Files (write-tree)
  6. Conflict Strategies
  7. Append with Deduplication
  8. Backups
  9. Per-Entry Overrides in write-tree
  10. CLI Flags
  11. Dry-Run Mode
  12. 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.yaml

Run it:

scafctl run resolver -f read-file.yaml -o json --hide-execution
scafctl run resolver -f read-file.yaml -o json --hide-execution

Output:

{
  "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: true

Run it:

scafctl run solution -f write-file.yaml --output-dir /tmp/demo
scafctl run solution -f write-file.yaml --output-dir /tmp/demo

The 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: .env

Output:

{
  "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.log

Writing 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: filesWritten counts 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:

StrategyBehaviorUse Case
skip-unchangedSHA256 compare; skip if identical, overwrite if differentDefault. Idempotent re-generation
overwriteAlways replace the fileForced regeneration, CI pipelines
skipNever write if file existsOne-time scaffolding (initial setup)
errorReturn an error if file existsStrict safety
appendAppend content to end of file; create if missingLog 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-unchanged

Running 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: error

If 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: overwrite

Append 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: append

Append 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: true

If .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: true is only valid with onConflict: 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 replacing
  • skip-unchanged — backs up before overwriting when content differs
  • append — 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: true

Output 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 version

Strategy 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 skip

The --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 --backup
scafctl run solution -f solution.yaml --output-dir ./out --backup

Both flags work with run solution and run provider:

scafctl run provider file operation=write path=config.yaml content="new" --on-conflict overwrite --backup
scafctl run provider file operation=write path=config.yaml content="new" --on-conflict overwrite --backup

Dry-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 json
scafctl run solution -f solution.yaml --output-dir ./out --dry-run -o json

Dry-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-run

CI Pipeline with Force Overwrite and Backup#

workflow:
  actions:
    ci-generate:
      provider: file
      inputs:
        operation: write-tree
        basePath: ./generated
        entries: { rslvr: rendered }
        onConflict: overwrite
        backup: true

Or via CLI flags:

scafctl run solution -f solution.yaml --output-dir ./generated --on-conflict overwrite --backup
scafctl run solution -f solution.yaml --output-dir ./generated --on-conflict overwrite --backup

One-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: skip

Mixed 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 backup

Strict Safety Check#

Fail the entire solution if any output file would be overwritten:

scafctl run solution -f solution.yaml --output-dir ./out --on-conflict error
scafctl run solution -f solution.yaml --output-dir ./out --on-conflict error

For 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: true

Next Steps#