git clone https://github.com/ScaleCommerce-DEV/scdev.git
cd scdev
make build # Build binary
make test # Run unit testsRequirements: Go 1.25+, Docker Desktop (for integration tests)
cmd/ # Cobra commands (one file per command)
internal/
config/ # Config parsing, defaults, variable substitution
create/ # Template resolution, download, copy (scdev create)
project/ # Project lifecycle (start, stop, down, exec, routing)
runtime/ # Docker abstraction (interface + DockerCLI implementation)
services/ # Shared infrastructure (router, mail, adminer, redis)
mutagen/ # Mutagen binary wrapper
state/ # Global project registry (~/.scdev/state.yaml)
tools/ # External tool management (mkcert, just, mutagen)
firstrun/ # First-time setup (certs, shared network)
ssl/ # Certificate management
ui/ # Terminal output (colors, hyperlinks, markdown)
skills/scdev/ # AI agent skill (SKILL.md + references/)
templates/ # Template authoring guide
testdata/projects/ # Test fixtures (minimal, full, variables, mutagen)
Run on every change before committing. Fast (seconds), no external dependencies.
make test # All unit tests
go test ./internal/create/ -v # Single package
go test ./... -run TestValidateName # Single testUnit tests use the mock runtime (internal/runtime/mock.go) for Docker operations and test fixtures in testdata/projects/.
Run before releases, after refactoring core logic (project lifecycle, routing, Mutagen), or when changing code that unit tests can't cover. Requires Docker - spins up real containers.
make test-integration # All integration tests
go test -v -tags=integration -count=1 ./internal/project/ # Single packageIntegration tests are tagged with //go:build integration so they don't run during make test. They create real Docker containers, networks, and volumes, and clean up after themselves (including state registry).
- Unit tests: Use the mock runtime. Test config parsing, variable substitution, name validation, label generation, etc.
- Integration tests: Use real Docker. Test full lifecycle (start/stop/down), exec, routing, Mutagen sync. Always defer
proj.Down(ctx, false)for cleanup.Down()handles state unregistration and router port refresh automatically. - Shared service restoration: Integration tests that tear down shared services (router, mail, db, redis) must snapshot what's running before the test and restore it afterward. Use
snapshotSharedServices/restoreSharedServiceshelpers. Without this, tests silently break the developer's running environment. - Test fixtures: Add to
testdata/projects/for config loading tests. Keep fixtures minimal.
- Create
cmd/<name>.gowith a Cobra command - Register with
rootCmd.AddCommand()ininit() - If the command needs Docker, call
requireDocker(ctx)at the top ofRunE - Follow the existing pattern: context with timeout,
project.Load(), return errors
var myCmd = &cobra.Command{
Use: "mycommand",
Short: "Brief description",
RunE: runMyCommand,
}
func init() {
rootCmd.AddCommand(myCmd)
}
func runMyCommand(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := requireDocker(ctx); err != nil {
return err
}
proj, err := project.Load()
if err != nil {
return err
}
// ...
}This has multiple touch points that are easy to miss:
- Create
internal/services/<service>.gowith container name constant and config function - Add
Start<Service>,Stop<Service>,<Service>Status,Connect<Service>ToProject,Disconnect<Service>FromProjectmethods tomanager.go - Pass network aliases in
Connect<Service>ToProject- without aliases, the service won't be resolvable by its short name from project containers (e.g.,mailinstead ofscdev_mail) - Add entry to
sharedServiceRegistry()incmd/services.go(handles start/stop/status/recreate) - Add connect/disconnect methods to
internal/project/shared_services.gousingconnectSharedServicehelper - Add image constant to
internal/config/defaults.go - Add to
ProjectSharedConfigininternal/config/config.go - Update
cmd/info.goto display the service status
Link networks enable cross-project container communication. The implementation spans:
internal/state/state.go-LinkEntry,LinkMembertypes and CRUD methods (CreateLink,DeleteLink,AddLinkMembers,RemoveLinkMembers,GetLinksForProject)internal/project/links.go-connectLinks()anddisconnectLinks()called during Start/Down lifecyclecmd/link.go- All subcommands (create, delete, join, leave, ls, status)
Links are stored in the global state file (~/.scdev/state.yaml), not in project config. They are a runtime relationship between projects, not a property of any single project. Each link creates a dedicated Docker network (scdev_link_<name>) so different link groups stay isolated from each other.
On scdev start, a project checks if it's a member of any links and auto-connects. On scdev down, it disconnects. Container DNS resolution happens automatically via Docker's embedded DNS - no explicit network aliases are needed since containers are already named with the <service>.<project>.scdev pattern.
scdev rename changes the project name and migrates all Docker resources. The implementation lives in:
internal/project/rename.go- CoreRename()method andupdateConfigName()helperinternal/state/state.go-RenameProject()atomically updates the project entry and all link membershipscmd/rename.go- CLI command with validation and confirmation
The rename process: stop containers, migrate volumes (create new + copy data via temp alpine container + remove old), remove old network, update state, rewrite name: in config.yaml (preserving formatting), reload and start. Docker has no native volume rename, so CopyVolume() on the Runtime interface handles the data migration.
Enables future Podman support. The Runtime interface (internal/runtime/runtime.go) abstracts all container operations. The DockerCLI implementation shells out to docker. Swapping to Podman means implementing the same interface with podman commands.
Each .scdev/commands/<name>.just file becomes a scdev <name> command. We chose just over Makefiles because:
- No build system baggage (no implicit rules, no tab sensitivity issues)
- Multiple recipes per file (e.g.,
scdev testruns default,scdev test watchruns thewatchrecipe) - Justfiles are discoverable - agents and developers can
ls .scdev/commands/to see all available commands - Just supports arguments, dependencies, and conditional logic
Commands run on the host, not in containers. Use scdev exec app <cmd> inside justfiles to run commands in containers.
Unlike Docker Compose, scdev does not require a top-level volumes: section. Named volumes are detected automatically from service volumes: entries - if the source doesn't start with /, ., or ${, it's a named volume. This reduces config boilerplate and eliminates a common source of "volume not declared" errors.
variables: defines ${VAR} placeholders substituted throughout the config file (e.g., sharing a DB password between app and db services). They are NOT passed to containers. environment: defines actual env vars passed to containers. This separation prevents config-internal values from leaking into the container runtime.
Docker bind mounts on macOS are slow - pnpm install takes 5x longer than native. Mutagen syncs files bidirectionally at native speed. It's auto-enabled on macOS and disabled on Linux (where bind mounts are already fast). The mutagen.ignore list keeps dependency dirs (node_modules, vendor) inside the container.
internal/runtime/mock.go implements the Runtime interface with in-memory state. This lets unit tests verify project lifecycle logic (start, stop, down, volume discovery, label generation) without Docker. Integration tests use the real DockerCLI implementation.
Two-pass variable substitution in internal/config/loader.go:
- First pass: substitute built-in variables (
${PROJECTDIR},${PROJECTPATH},${SCDEV_DOMAIN}, etc.) to resolve thename:field - Second pass: add
${PROJECTNAME}+ user-definedvariables:to the map, re-substitute everything
User variables can reference built-in vars (e.g., DB_NAME: ${PROJECTNAME}_db) but not other user variables (map iteration order is undefined in Go).
buildContainerConfig() in project.go is the single source of truth for container configuration. Both startServiceWithMutagen() (creating containers) and serviceNeedsRecreate() (comparing against running containers) use it. It stamps a scdev.config-hash label (deterministic sha256 of image, env, volumes, command, working dir, routing labels, ports, aliases, and network). scdev update recreates any service whose stamped hash differs from the freshly built one. Pre-hash containers have no label and get recreated once on first update after upgrading.
Three docs must stay in sync with the code:
| File | Purpose | Update when |
|---|---|---|
README.md |
User-facing docs and marketing | New commands, config options, features |
templates/README.md |
Template authoring guide | Config changes, scaffolding patterns, new framework notes |
CLAUDE.md |
Agent guidance (decisions, gotchas) | Architecture changes, new conventions, non-obvious patterns |
Don't duplicate information across docs. README has config reference and examples. templates/README.md has template-specific patterns. CLAUDE.md has decisions and gotchas only.
Templates enable scdev create <template> <name> for one-command project scaffolding. See the Template Authoring Guide for the full reference.
Key patterns:
.setup-completemarker solves the container startup vs setup circular dependency- Scaffold in-place (
--force) for frameworks that support non-empty dirs (Nuxt) - Scaffold in /tmp for frameworks that require empty dirs (Symfony) - safe for PHP, not for Node.js
setup.justruns on host with interactive terminal - framework prompts work here but not in the container entrypoint
- Never use em-dashes (-). Use regular hyphens (-) everywhere.
- Error messages:
fmt.Errorf("failed to <action>: %w", err)- wrap with context - User-facing output:
fmt.Printffor status,fmt.Printlnfor section headers - Top-level progress markers during multi-step flows (setup, start, sync): use
ui.StatusStep(msg, plainMode)instead of plainfmt.Println. Adds two blank lines + cyan▶+ bold text so framework messages stand out from verbose nested command output. Same styling is exposed asscdev step <msg>for template justfiles. - Commands return errors (Cobra handles display with
SilenceUsage: true)
- Update
CHANGELOG.md- add new## vX.Y.Zsection at top - Run
make testandmake test-integration - Commit, tag, push:
git add -A && git commit -m "Release vX.Y.Z" git tag vX.Y.Z && git push origin main && git push origin vX.Y.Z
- CI builds binaries for darwin/linux (arm64/amd64) and creates a GitHub Release with changelog
- Users update via
scdev self-update