StackPanel
Dev Environment

Shell Hooks & Files

Run code on shell entry and generate files from your Nix config

Shell Hooks

Shell hooks run every time you enter the dev environment—whether through direnv, nix develop, or the Stackpanel agent reloading your shell. They're useful for one-time setup, printing status information, or ensuring local state is correct.

Adding Hooks

Hooks are defined under stackpanel.devshell.hooks:

stackpanel.devshell.hooks.main = [
  ''
    echo "Welcome to the dev environment!"
    bun install --silent
  ''
];

Multiple modules can contribute hooks to the same key. They're concatenated in the order the module system resolves them.

Hook Ordering

If you need hooks to run in a specific order, use different keys:

# Runs first (keys are sorted alphabetically)
stackpanel.devshell.hooks."00-env" = [
  ''
    export MY_VAR="computed-at-shell-entry"
  ''
];

# Runs after
stackpanel.devshell.hooks."99-status" = [
  ''
    echo "Environment ready. MY_VAR=$MY_VAR"
  ''
];

Common Patterns

# Install dependencies on shell entry
stackpanel.devshell.hooks.main = [
  ''
    if [ -f package.json ]; then
      bun install --silent
    fi
  ''
];

# Ensure local database exists
stackpanel.devshell.hooks.db = [
  ''
    if ! psql -lqt | cut -d \| -f 1 | grep -qw myapp; then
      createdb myapp 2>/dev/null || true
    fi
  ''
];

Shell hooks run on every shell entry, so keep them fast. Avoid network calls or long-running processes. If something takes more than a second or two, move it to a script that developers run explicitly.

Generated Files

Stackpanel can generate files into your project workspace from Nix configuration. This is one of the most powerful features—it lets modules co-locate file contributions with the logic that requires them.

For the conceptual overview of why this matters, see File Generation. This page covers the practical API.

Basic Usage

Files are defined under stackpanel.files.entries, keyed by their path relative to the project root:

stackpanel.files.entries.".editorconfig" = {
  type = "text";
  text = ''
    root = true

    [*]
    indent_style = space
    indent_size = 2
    end_of_line = lf
    insert_final_newline = true
  '';
};

File Types

The type field determines how contributions from multiple modules are merged.

line-set

A deduplicated, sorted set of lines. Each module contributes a list, and the final file contains all unique entries.

stackpanel.files.entries.".gitignore" = {
  type = "line-set";
  content = [
    "node_modules"
    "dist"
    ".env.local"
    ".stack/state/"
  ];
};

Best for: .gitignore, .prettierignore, .dockerignore, .eslintignore.

json

Deep-merged JSON. Multiple modules can contribute keys, and nested objects are recursively merged.

stackpanel.files.entries.".vscode/settings.json" = {
  type = "json";
  content = {
    "editor.formatOnSave" = true;
    "editor.defaultFormatter" = "esbenp.prettier-vscode";
    "nix.enableLanguageServer" = true;
  };
};

Best for: tsconfig.json, VS Code settings, any .json config file.

text

Raw text content. No merging—if multiple modules write to the same path, the last one wins (use mkDefault / mkForce to control priority).

stackpanel.files.entries."docker-compose.yml" = {
  type = "text";
  text = ''
    version: "3.8"
    services:
      db:
        image: postgres:16
        ports:
          - "${toString cfg.postgresPort}:5432"
  '';
};

Best for: Dockerfiles, shell scripts, templates, any file fully owned by one module.

Interpolating Nix Values

Because file contents are defined in Nix, you can interpolate any Nix expression—config values, computed ports, package paths:

stackpanel.files.entries.".env.development" = {
  type = "text";
  text = ''
    DATABASE_URL=postgresql://localhost:${toString config.stackpanel.ports.postgres}/dev
    REDIS_URL=redis://localhost:${toString config.stackpanel.ports.redis}
    NODE_ENV=development
  '';
};

This is a key advantage over hand-maintained config files: your generated files always reflect the actual state of your environment.

Conditional File Generation

Combine file entries with mkIf to only generate files when a feature is enabled:

config = lib.mkIf config.stackpanel.globalServices.postgres.enable {
  stackpanel.files.entries."drizzle.config.ts" = {
    type = "text";
    text = ''
      import { defineConfig } from "drizzle-kit";
      export default defineConfig({
        dialect: "postgresql",
        dbCredentials: {
          url: process.env.DATABASE_URL!,
        },
      });
    '';
  };
};

Checking Generated File Status

The Stackpanel Agent tracks which files were generated and can detect drift—when a file on disk doesn't match what the config says it should be. You can check file status through the CLI:

stackpanel status

Or through Studio, which shows generated files alongside their staleness status.

Combining Hooks and Files

A common pattern is to use hooks that reference generated files:

{
  # Generate a setup script
  stackpanel.files.entries.".stack/setup.sh" = {
    type = "text";
    text = ''
      #!/usr/bin/env bash
      set -euo pipefail
      echo "Running project setup..."
      bun install
      bun run db:migrate
    '';
  };

  # Run it on shell entry (only if needed)
  stackpanel.devshell.hooks.setup = [
    ''
      if [ ! -f node_modules/.package-lock.json ]; then
        bash .stack/setup.sh
      fi
    ''
  ];
}

Reference

On this page