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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: python scripts/check_action_pins.py
- name: Landscape table matches the fetched competitor data
# The table states its own method ("fetched via the GitHub API")
# but was typed by hand. In a sibling repo that drifted into
# publishing that a live competitor's repo was gone.
run: python scripts/generate_landscape.py --check
- name: README example matches the real renderer
# The README used to show a hand-written scan with a rule id no code
# emits, under the caption "examples from actual scans". This renders a
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,23 @@ These are independent projects: no shared library, no coupled releases, and each

<!-- /related-projects -->

## How this compares

9 projects are tracked in [`docs/competitive-analysis.md`](docs/competitive-analysis.md),
fetched from the GitHub API on 2026-09-09 and committed to
[`data/competitor-meta.json`](data/competitor-meta.json).

Almost all of them — Nix, devenv, Devbox, mise, asdf, direnv, Dev Containers — *prevent*
environment drift by declaring the environment up front. That is a different shape of
solution, and usually a better one when you can adopt it. DevRepro Doctor exists for the
case you cannot: a machine that is already broken, that you did not configure, and that has
to work today. It diagnoses and explains rather than replacing.

## Citation

If this tool contributed to published work, cite it via
[`CITATION.cff`](CITATION.cff) — GitHub renders a "Cite this repository" control from it.

## License

Apache License 2.0 — see [LICENSE](LICENSE). Creator attribution:
Expand Down
108 changes: 108 additions & 0 deletions data/competitor-meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
{
"fetched_utc": "2026-09-09T10:16:42+00:00",
"tool": "gh api (authenticated)",
"repos": {
"NixOS/nix": {
"repo": "NixOS/nix",
"license_spdx": "LGPL-2.1",
"stars": 17660,
"pushed_at": "2026-09-09T02:54:11Z",
"archived": false,
"description": "Nix, the purely functional package manager",
"latest_release": null
},
"cachix/devenv": {
"repo": "cachix/devenv",
"license_spdx": "Apache-2.0",
"stars": 7618,
"pushed_at": "2026-09-07T22:15:33Z",
"archived": false,
"description": "Fast, Declarative, Reproducible, and Composable Developer Environments using Nix",
"latest_release": {
"tag": "v2.3",
"published_at": "2026-09-07T13:08:42Z"
}
},
"jetify-com/devbox": {
"repo": "jetify-com/devbox",
"license_spdx": "Apache-2.0",
"stars": 12344,
"pushed_at": "2026-09-04T00:07:25Z",
"archived": false,
"description": "Instant, easy, and predictable development environments",
"latest_release": {
"tag": "0.18.0",
"published_at": "2026-08-16T19:43:10Z"
}
},
"jdx/mise": {
"repo": "jdx/mise",
"license_spdx": "MIT",
"stars": 33693,
"pushed_at": "2026-09-09T00:49:45Z",
"archived": false,
"description": "dev tools, env vars, task runner",
"latest_release": {
"tag": "v2026.9.3",
"published_at": "2026-09-08T13:55:29Z"
}
},
"asdf-vm/asdf": {
"repo": "asdf-vm/asdf",
"license_spdx": "MIT",
"stars": 25571,
"pushed_at": "2026-09-03T13:48:57Z",
"archived": false,
"description": "Extendable version manager with support for Ruby, Node.js, Elixir, Erlang & more",
"latest_release": {
"tag": "v0.20.0",
"published_at": "2026-07-07T14:05:10Z"
}
},
"direnv/direnv": {
"repo": "direnv/direnv",
"license_spdx": "MIT",
"stars": 15430,
"pushed_at": "2026-03-31T18:52:06Z",
"archived": false,
"description": "unclutter your .profile",
"latest_release": {
"tag": "v2.37.1",
"published_at": "2025-07-20T10:35:29Z"
}
},
"devcontainers/cli": {
"repo": "devcontainers/cli",
"license_spdx": "MIT",
"stars": 2947,
"pushed_at": "2026-09-03T07:27:05Z",
"archived": false,
"description": "A reference implementation for the specification that can create and configure a dev container from a devcontainer.json.",
"latest_release": null
},
"loft-sh/devpod": {
"repo": "loft-sh/devpod",
"license_spdx": "MPL-2.0",
"stars": 15198,
"pushed_at": "2025-11-14T06:33:38Z",
"archived": false,
"description": "Codespaces but open-source, client-only and unopinionated: Works with any IDE and lets you use any cloud, kubernetes or just localhost docker.",
"latest_release": {
"tag": "v0.6.15",
"published_at": "2025-03-10T14:33:39Z"
}
},
"coder/coder": {
"repo": "coder/coder",
"license_spdx": "AGPL-3.0",
"stars": 14410,
"pushed_at": "2026-09-09T10:13:57Z",
"archived": false,
"description": "Secure environments for developers and their agents",
"latest_release": {
"tag": "v2.36.4",
"published_at": "2026-09-01T05:07:25Z"
}
}
}
}
21 changes: 20 additions & 1 deletion docs/competitive-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ Absence claims are only made where documented or structurally implied by the
project's stated scope; otherwise fields are `null`. Re-verify before quoting
externally. No proprietary code, assets or branding is copied from any project.


<!-- landscape:generated -->
## Landscape snapshot (fetched 2026-09-09)

| Project | License | Stars | Last push | Latest release | Status |
|---|---|---|---|---|---|
| [jdx/mise](https://github.com/jdx/mise) | MIT | 33,693 | 2026-09-09 | v2026.9.3 (2026-09-08) | active |
| [asdf-vm/asdf](https://github.com/asdf-vm/asdf) | MIT | 25,571 | 2026-09-03 | v0.20.0 (2026-07-07) | active |
| [NixOS/nix](https://github.com/NixOS/nix) | LGPL-2.1 | 17,660 | 2026-09-09 | — | active |
| [direnv/direnv](https://github.com/direnv/direnv) | MIT | 15,430 | 2026-03-31 | v2.37.1 (2025-07-20) | active |
| [loft-sh/devpod](https://github.com/loft-sh/devpod) | MPL-2.0 | 15,198 | 2025-11-14 | v0.6.15 (2025-03-10) | active |
| [coder/coder](https://github.com/coder/coder) | AGPL-3.0 | 14,410 | 2026-09-09 | v2.36.4 (2026-09-01) | active |
| [jetify-com/devbox](https://github.com/jetify-com/devbox) | Apache-2.0 | 12,344 | 2026-09-04 | 0.18.0 (2026-08-16) | active |
| [cachix/devenv](https://github.com/cachix/devenv) | Apache-2.0 | 7,618 | 2026-09-07 | v2.3 (2026-09-07) | active |
| [devcontainers/cli](https://github.com/devcontainers/cli) | MIT | 2,947 | 2026-09-03 | — | active |

Rows are generated from `data/competitor-meta.json` by `scripts/fetch_competitor_meta.py`, which reads the GitHub API. Star counts and dates are facts about the repositories on the fetch date, not judgements. Nothing here claims a project lacks a feature: where a capability was not verified it is absent from this table rather than asserted as missing.
<!-- /landscape:generated -->

## Positioning statement

DevRepro Doctor is **not** an environment manager. It is the *project-aware
Expand Down Expand Up @@ -82,4 +101,4 @@ Legend: ✅ first-class · partial · – absent/not applicable.
- Package/build management (Nix store model, mise/asdf shims) — out of scope.
- Cloud workspace provisioning (Coder/DevPod) — different problem.
- Auto-executing generated environment files without review.
- Telemetry of any kind.
- Telemetry of any kind.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ ignore = [
]

[tool.ruff.lint.per-file-ignores]
# Same shared-script situation: the gh endpoint is built from the hardcoded
# REPOS list above it, not from user input.
"scripts/fetch_competitor_meta.py" = ["S603", "S607", "RUF100"]
# Shared verbatim across the four sibling repositories, whose ruff
# rule sets differ: S310 must be suppressed where it is enabled, and
# that suppression must not read as unused (RUF100) where it is not.
Expand Down
117 changes: 117 additions & 0 deletions scripts/fetch_competitor_meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Fetch live GitHub metadata for competitor repos via the authenticated gh CLI.

Writes data/competitor-meta.json and prints a compact TSV summary.
Access date is recorded for evidence purposes. Temp-free: writes only the
final artifact.
"""

from __future__ import annotations

import json
import subprocess
from datetime import UTC, datetime
from pathlib import Path

REPOS = [
"NixOS/nix",
"cachix/devenv",
"jetify-com/devbox",
"jdx/mise",
"asdf-vm/asdf",
"direnv/direnv",
"devcontainers/cli",
"loft-sh/devpod",
"coder/coder",
]


class FetchError(RuntimeError):
"""A competitor could not be fetched, and the run must not pretend it could."""


def gh_json(endpoint: str, *, allow_404: bool = False) -> dict[str, object] | None:
"""Fetch `endpoint`, raising rather than silently returning None.

This used to swallow every failure into `None`, and the caller recorded
that as `{"error": "repo-not-found-or-error"}` in the artifact. The
competitive analysis then transcribed it as a fact about the world:
"Optic -- repo gone (404)". Optic's repository is not gone. It is
`opticdev/optic`, archived with 1,534 stars; this script was asking for
`useoptic/optic`, which does not exist. A typo in a constant became a
published claim that an archived competitor had disappeared, which is
both wrong and the opposite of useful -- an archived incumbent in this
exact domain is the most interesting fact in the file.

`allow_404` is for endpoints where absence is a real answer (a repo with
no releases). Everywhere else a 404 stops the run so a human decides
whether the project moved or genuinely went away.
"""
try:
raw = subprocess.run(
["gh", "api", endpoint],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
check=True,
).stdout
return json.loads(raw) # type: ignore[no-any-return]
except subprocess.CalledProcessError as exc:
if allow_404 and "404" in (exc.stderr or ""):
return None
detail = (exc.stderr or "").strip()[:200]
raise FetchError(
f"{endpoint}: {detail}. "
"If the project moved, update REPOS. If it really is gone, say so "
"deliberately rather than letting a failed fetch write it."
) from exc
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
raise FetchError(f"{endpoint}: {type(exc).__name__}") from exc


def main() -> None:
results: dict[str, dict[str, object]] = {}
for repo in REPOS:
meta = gh_json(f"repos/{repo}")
rel = gh_json(f"repos/{repo}/releases/latest", allow_404=True)
entry: dict[str, object] = {"repo": repo}
if meta is None: # pragma: no cover - gh_json raises instead now
raise FetchError(f"{repo}: no metadata returned")
else:
lic = meta.get("license") or {}
entry.update(
{
"license_spdx": lic.get("spdx_id"),
"stars": meta.get("stargazers_count"),
"pushed_at": meta.get("pushed_at"),
"archived": meta.get("archived"),
"description": meta.get("description"),
}
)
if rel is None:
entry["latest_release"] = None
else:
entry["latest_release"] = {
"tag": rel.get("tag_name"),
"published_at": rel.get("published_at"),
}
results[repo] = entry

payload = {
"fetched_utc": datetime.now(UTC).isoformat(timespec="seconds"),
"tool": "gh api (authenticated)",
"repos": results,
}
out = Path("data/competitor-meta.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
for repo, entry in results.items():
print(
f"{repo} | {entry.get('license_spdx')} | {entry.get('stars')}"
f" | {entry.get('pushed_at')} | {entry.get('latest_release')}"
)


if __name__ == "__main__":
main()
Loading