Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .config/lefthook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Git hooks. Lefthook discovers this file itself, by searching
# `lefthook.*` -> `.lefthook.*` -> `.config/lefthook.*` and stopping at the
# first match — which is why it is the one config here not passed by path, and
# why a stray lefthook.yml at the repo root would silently shadow it.
#
# Keep min_version in step with .devcontainer/mise.toml.
min_version: 2.1.10

# The split between the two hooks is about the network.
#
# `npm test` fetches the spec bundles from musher-dev/spec on every run — that
# is the point of it — so it cannot be a pre-commit job: a commit on a train
# would fail for a reason that has nothing to do with the commit. Pre-commit
# holds the checks that are local and fast; pre-push holds the one that is
# neither.
pre-commit:
parallel: true
jobs:
- name: types
glob: 'tests/**/*.ts'
run: task typecheck

- name: shell
glob: '.devcontainer/**/*.sh'
run: task check:shell

- name: workflow
glob: '.github/workflows/*.{yml,yaml}'
run: task check:workflow

pre-push:
jobs:
- name: validate
run: task test

commit-msg:
jobs:
- name: conventional-commits
run: |
sh -ceu '
first_line=$(head -n 1 "$1")
case "$first_line" in
Merge*) exit 0 ;;
esac
pattern="^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\\(.+\\))?(!)?: .+"
if ! printf "%s\n" "$first_line" | grep -Eq "$pattern"; then
printf "%s\n" "ERROR: Commit message does not follow Conventional Commits format." >&2
printf "%s\n" "" >&2
printf "%s\n" " Expected: <type>(<scope>): <description>" >&2
printf " Got: %s\n" "$first_line" >&2
printf "%s\n" "" >&2
printf "%s\n" " Types: feat, fix, chore, docs, style, refactor, perf, test, ci, build, revert" >&2
printf "%s\n" " Scopes: items, tests, devcontainer, ci, docs, repo, deps" >&2
exit 1
fi
' -- {1}
29 changes: 29 additions & 0 deletions .devcontainer/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# ============================================================
# Dev Container Environment Template
# ============================================================
# On first container build, `initializeCommand` copies this file to
# `.devcontainer/.env` (gitignored) on the host. `runArgs --env-file`
# then loads it into the container, so shells and `task` runs see the
# same values.
#
# This repository holds YAML and validates it against the published
# spec. It runs no services and needs no credentials, so there is
# nothing required here — the file exists so the bootstrap has a valid
# target and so per-developer overrides have somewhere to live.
#
# Drift check: `task env:check` compares this file against your local
# `.env` and flags missing keys.
#
# Convention (three states):
# 1. Filled defaults — `VAR=value` safe defaults; override only if needed.
# 2. Required (empty) — `VAR=` must be filled in; startup warns.
# 3. Optional overrides — `# VAR=value` uncomment to enable.
# ============================================================


# === Optional overrides =====================================
# Skip the Claude Code install on a slow connection.
# MUSHER_INSTALL_CLAUDE=0

# Per-request timeout when fetching a schema, in milliseconds.
# MUSHER_SPEC_TIMEOUT_MS=15000
34 changes: 34 additions & 0 deletions .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"features": {
"ghcr.io/devcontainers-extra/features/go-task:1": {
"version": "1.0.6",
"resolved": "ghcr.io/devcontainers-extra/features/go-task@sha256:4d1db153919976cadd3209ca05d655a761a01707767716994dad677b4538dc1b",
"integrity": "sha256:4d1db153919976cadd3209ca05d655a761a01707767716994dad677b4538dc1b"
},
"ghcr.io/devcontainers/features/common-utils:2": {
"version": "2.5.9",
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a",
"integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a"
},
"ghcr.io/devcontainers/features/git:1": {
"version": "1.3.8",
"resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2",
"integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2"
},
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "1.1.1",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:94879eebb6a0e4e2f197de9f12db7427cb4a25b82d93c55239ce8c8fc394a1b4",
"integrity": "sha256:94879eebb6a0e4e2f197de9f12db7427cb4a25b82d93c55239ce8c8fc394a1b4"
},
"ghcr.io/devcontainers/features/node:2": {
"version": "2.1.0",
"resolved": "ghcr.io/devcontainers/features/node@sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857",
"integrity": "sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857"
},
"ghcr.io/lukewiwa/features/shellcheck:0": {
"version": "0.2.3",
"resolved": "ghcr.io/lukewiwa/features/shellcheck@sha256:9d61a060404fb127ae0c3aea3adbf85869ba575ab8e612f168c82c48ef0c502e",
"integrity": "sha256:9d61a060404fb127ae0c3aea3adbf85869ba575ab8e612f168c82c48ef0c502e"
}
}
}
135 changes: 135 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// musher-dev/catalog dev container.
//
// Trimmed from the same musher-dev/development-container template
// musher-dev/spec uses, and trimmed further. This repository holds YAML and
// validates it against the published spec: it builds no images, runs no
// services, needs no database, and has no compose stack. Docker-in-Docker,
// Python, Go, Java, Deno, uv, Bun and the postgres client are all deliberately
// absent.
//
// Kept: Node for the test suite, Task as the runner, shellcheck and actionlint
// for the scripts and workflows, gh for release work, and Claude Code.
{
"name": "Musher Catalog",

// Pinned to the LTS, not the floating :ubuntu tag — that one rolls to interim
// releases some upstream Features do not support.
"image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04",

"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind,consistency=cached",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",

// Every Feature is version-pinned and captured in devcontainer-lock.json.
// Tools with no Feature (lefthook, actionlint) live in
// .devcontainer/mise.toml; Claude Code self-updates via its own installer.
//
// Node is pinned to 24 because the test suite is TypeScript run directly by
// Node's type stripping — there is no build step, and no version below 23.6
// can execute it.
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"installZsh": true,
"configureZshAsDefaultShell": true,
"installOhMyZsh": true,
"installOhMyZshConfig": true,
"upgradePackages": true
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": { "version": "2.96.0" },
"ghcr.io/devcontainers/features/node:2": { "version": "24.18.0" },
"ghcr.io/devcontainers-extra/features/go-task:1": { "version": "3.52.0" },
"ghcr.io/lukewiwa/features/shellcheck:0": { "version": "v0.11.0" }
},

"containerUser": "vscode",
"remoteUser": "vscode",

"init": true,
"shutdownAction": "stopContainer",

// Loaded at `docker run` time so shells and `task` runs see the same values.
"runArgs": ["--env-file", "${localWorkspaceFolder}/.devcontainer/.env"],

// Named volumes, so a `gh auth login` and a Claude sign-in survive a rebuild.
"mounts": [
"source=musher-catalog-${devcontainerId}-gh-config,target=/home/vscode/.config/gh,type=volume",
"source=musher-catalog-${devcontainerId}-claude-config,target=/home/vscode/.claude,type=volume"
],

"containerEnv": {
// mise's manifest lives under .devcontainer/, not the repo root.
"MISE_GLOBAL_CONFIG_FILE": "${containerWorkspaceFolder}/.devcontainer/mise.toml",
"MISE_TRUSTED_CONFIG_PATHS": "${containerWorkspaceFolder}",

"XDG_CACHE_HOME": "/home/vscode/.cache",
"NPM_CONFIG_CACHE": "/home/vscode/.cache/npm",

"MUSHER_INSTALL_CLAUDE": "1"
},

"remoteEnv": {
"PATH": "/home/vscode/.local/share/mise/shims:/home/vscode/.local/bin:${containerEnv:PATH}"
},

// Host-side: creates .devcontainer/.env from the template and strips CRLF so
// --env-file above has a valid target on a fresh clone. Run through `bash -c`
// so CRLF in the script files cannot break the strip-and-run bootstrap.
"initializeCommand": [
"bash",
"-c",
"find .devcontainer/scripts -name '*.sh' -exec sed -i 's/\\r$//' {} + 2>/dev/null; bash .devcontainer/scripts/initialize.sh"
],

"waitFor": "postCreateCommand",
"postCreateCommand": {
"fix-crlf": "find .devcontainer/scripts -type f -name '*.sh' -exec sed -i 's/\\r$//' {} +",
"setup": ["bash", ".devcontainer/scripts/post-create.sh"]
},
"postStartCommand": ["bash", ".devcontainer/scripts/startup.sh"],

"customizations": {
"vscode": {
"extensions": [
// VS Code does not read .editorconfig on its own, and .editorconfig is
// this repository's single source for whitespace and line endings.
"editorconfig.editorconfig",
"redhat.vscode-yaml",
"timonwong.shellcheck",
"eamodio.gitlens",
"bierner.markdown-mermaid",
"github.copilot",
"github.copilot-chat"
],
"settings": {
"editor.formatOnSave": true,
"editor.rulers": [80, 100],

// Whitespace, line endings and encoding are NOT set here. .editorconfig
// states them once, for every editor, and the EditorConfig extension
// above applies them inside the container. Restating them made VS Code
// trim trailing whitespace from Markdown, where two trailing spaces are
// a hard line break and .editorconfig deliberately preserves them.

"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": {
"bash": { "path": "/bin/bash" },
"zsh": { "path": "/bin/zsh" }
},

// Authoring aid: red-squiggle an item in the editor against the same
// schemas `npm test` validates it with — the tip of the public
// musher-dev/spec repository. One source here and in the test suite, or
// the editor and the build disagree about what an item has to satisfy.
"yaml.schemas": {
"https://raw.githubusercontent.com/musher-dev/spec/main/specifications/listing/v1/schemas/dist/listing.schema.json": "items/*/listing.yaml",
"https://raw.githubusercontent.com/musher-dev/spec/main/specifications/blueprint/v1/schemas/dist/blueprint.schema.json": "items/*/blueprint.yaml",
"https://raw.githubusercontent.com/musher-dev/spec/main/specifications/component/v1/schemas/dist/component.schema.json": "items/*/components/*.yaml"
},

// The corpus is authored YAML, not generated. Keep the formatter off it
// so a drive-by save does not reflow a document nobody edited.
"[yaml]": { "editor.formatOnSave": false }
}
}
}
}
22 changes: 22 additions & 0 deletions .devcontainer/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Developer tools with no devcontainer Feature.
#
# Everything that has a Feature is pinned in devcontainer.json and baked into
# the image. Everything else is pinned here, whatever it is distributed as —
# mise resolves each tool for the container's own CPU architecture, which is
# what a hand-placed release binary does not do. Claude Code is the exception:
# it self-updates through its own installer (scripts/lib/base-setup.sh).
#
# actionlint must stay in step with .github/workflows/*.yml if a workflow ever
# installs it itself. Two environments, one version — CI is not a mise host and
# does not read this file.
#
# Pinned exactly rather than to a range. A range resolves the newest match
# through the GitHub API on every run, so a degraded API takes the whole setup
# down before it reaches a single check.
#
# Change a version here, then rebuild the container or run `mise install`.
# Docs: https://mise.jdx.dev

[tools]
"actionlint" = "1.7.11"
"npm:lefthook" = "2.1.10"
57 changes: 57 additions & 0 deletions .devcontainer/scripts/env-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# env-check.sh — CLI wrapper around the env-check library.
#
# Reports keys present in .devcontainer/.env.example but missing from the
# developer's .devcontainer/.env, and keys left at the empty "please fill this
# in" state.
#
# The logic lives in lib/env-check.sh so the MOTD can reuse it. This file
# exists so `task env:check` has something to invoke that is a real script —
# Task runs commands through its own shell, and `source` is not something to
# rely on there.
#
# Usage: bash .devcontainer/scripts/env-check.sh
# Returns: 0 when the local .env matches the template, 1 on drift.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly DEVCONTAINER_DIR="${SCRIPT_DIR}/.."
readonly ENV_FILE="${DEVCONTAINER_DIR}/.env"
readonly ENV_EXAMPLE="${DEVCONTAINER_DIR}/.env.example"

# shellcheck source=lib/env-check.sh
source "${SCRIPT_DIR}/lib/env-check.sh"

main() {
if [[ ! -f "${ENV_FILE}" ]]; then
echo "env: no .devcontainer/.env — run 'task env:reset' to create one" >&2
return 1
fi

local status=0

local missing
missing="$(env_check_drift "${ENV_FILE}" "${ENV_EXAMPLE}" 2>&1 || true)"
if [[ -n "${missing}" ]]; then
echo "env: keys in .env.example missing from .env:" >&2
awk '{print " - " $0}' <<< "${missing}" >&2
echo "env: run 'task env:reset' to sync" >&2
status=1
fi

local required
required="$(env_check_required "${ENV_FILE}" || true)"
if [[ -n "${required}" ]]; then
# Informational, not a failure: the three-state grammar in .env.example
# uses an empty value to mean "you must fill this in", and this repository
# ships none — but a developer may add one.
echo "env: keys awaiting a value:" >&2
awk '{print " - " $0}' <<< "${required}" >&2
fi

((status == 0)) && echo "env: .env matches .env.example"
return "${status}"
}

main "$@"
55 changes: 55 additions & 0 deletions .devcontainer/scripts/initialize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# initialize.sh — Host-side bootstrap for the dev container.
#
# Runs on the host (via devcontainer.json `initializeCommand`) BEFORE
# `docker run` is invoked. Because `runArgs --env-file` is evaluated at
# `docker run` time, the .env file must exist on the host before the
# container starts — that's why this work lives here, not in
# post-create.sh.
#
# Responsibilities:
# * Create .devcontainer/.env from .env.example on first clone.
# * Touch an empty .env if no example exists, so --env-file never hard-fails.
# * Strip CRLF from .env (Windows/WSL safety — docker --env-file
# rejects files with CRLF line endings).
#
# Idempotent: safe to run on every container start.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly DEVCONTAINER_DIR="${SCRIPT_DIR}/.."
readonly ENV_FILE="${DEVCONTAINER_DIR}/.env"
readonly ENV_EXAMPLE="${DEVCONTAINER_DIR}/.env.example"

log() {
echo "[initialize] $*" >&2
}

ensure_env_file() {
if [[ -f "${ENV_FILE}" ]]; then
return 0
fi
if [[ -f "${ENV_EXAMPLE}" ]]; then
log "Creating .devcontainer/.env from .env.example"
cp "${ENV_EXAMPLE}" "${ENV_FILE}"
else
log "No .env.example found; creating empty .devcontainer/.env"
: > "${ENV_FILE}"
fi
}

strip_crlf() {
[[ -f "${ENV_FILE}" ]] || return 0
if grep -q $'\r' "${ENV_FILE}" 2>/dev/null; then
log "Stripping CRLF from .devcontainer/.env"
sed -i 's/\r$//' "${ENV_FILE}"
fi
}

main() {
ensure_env_file
strip_crlf
}

main "$@"
Loading
Loading