9df126c7a9
Validate / docs-check (push) Successful in 23s
Validate / validate-base (push) Has started running
Validate / validate-omos (push) Has started running
Validate / validate-with-pi (push) Has been cancelled
Validate / validate-omos-with-pi (push) Has been cancelled
NPM_CONFIG_PREFIX is now /home/developer/.pi/npm-global, with that
prefix's bin/ prepended to PATH. Without this, 'pi install npm:<pkg>'
(and any 'npm install -g') by the developer user would EACCES against
the system prefix (/usr).
The new prefix lives on the devbox-pi-config named volume, so:
- User-installed pi packages (themes, skills, extensions) survive
container recreate AND image rebuild, complementing pi's auto-
restore from settings.json with one less cold-start step.
- A user-driven 'npm install -g @mariozechner/pi-coding-agent' lands
on the volume and wins over the baked pi via PATH order.
Build-time 'npm install -g' calls (opencode, pi, oh-my-opencode-slim)
are unaffected: the new ENVs are declared after those steps in the
Dockerfile, so the baked binaries still install to /usr at build time
and are not shadowed by the volume mount at runtime.
Verified end-to-end with a Bun-driven smoke test: as developer,
'npm install -g cowsay' inside the container succeeds, the binary
lands on PATH, and survives a fresh container against the same volume.
DOCKER_HUB.md regenerated (24997/25000 bytes, 3-byte headroom — was
138 before; future README additions to the persistence section need
to trim something else first).
Docs updated: Dockerfile inline comments, README persistence section,
AGENTS install contract, DOCKER_HUB persistence table, .env.example
notes, CHANGELOG Unreleased entry.
324 lines
12 KiB
Python
Executable File
324 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Generate DOCKER_HUB.md from README.md.
|
|
|
|
Rationale
|
|
---------
|
|
README.md is the authoritative source. DOCKER_HUB.md is a subset
|
|
intended for users pulling the pre-built image from Docker Hub — so
|
|
build-from-source instructions, developer setup (git hooks, gitleaks),
|
|
and CI/contribution content are dropped.
|
|
|
|
Docker Hub enforces a 25 kB limit on the full description field.
|
|
|
|
Usage
|
|
-----
|
|
Regenerate in place:
|
|
python3 scripts/generate-dockerhub-md.py
|
|
|
|
Fail if DOCKER_HUB.md is out of sync with what this script would emit
|
|
(run this in CI):
|
|
python3 scripts/generate-dockerhub-md.py --check
|
|
|
|
Design
|
|
------
|
|
Sections are selected and in some cases rewritten via `SECTION_RULES`
|
|
below. This keeps the transformation explicit and easy to audit — if
|
|
a new section is added to README.md that should also appear on Docker
|
|
Hub, extend SECTION_RULES rather than inventing implicit heuristics.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
README = REPO_ROOT / "README.md"
|
|
DOCKER_HUB = REPO_ROOT / "DOCKER_HUB.md"
|
|
|
|
# Max size for Docker Hub full_description (bytes, UTF-8).
|
|
MAX_SIZE_BYTES = 25_000
|
|
|
|
# Per-section transformation.
|
|
#
|
|
# Each key is a top-level section title as it appears in README.md
|
|
# (without the leading "## ").
|
|
#
|
|
# The value is one of:
|
|
# "keep" — include verbatim.
|
|
# "drop" — exclude entirely.
|
|
# "replace" — substitute a custom body (see REPLACEMENTS).
|
|
# "trim" — keep but drop selected level-3 sub-sections listed
|
|
# in TRIM_SUBSECTIONS[title].
|
|
#
|
|
# Unknown sections default to "drop" with a warning — forcing an
|
|
# explicit decision whenever README gains a new section.
|
|
SECTION_RULES: dict[str, str] = {
|
|
"Why?": "drop", # build-motivation, not user-facing
|
|
"Quick Start": "replace", # swap docker compose clone flow for docker run
|
|
"Features": "keep",
|
|
"Usage": "keep",
|
|
"Configuration": "trim", # drop dev-build sub-sections
|
|
"oh-my-opencode-slim (Multi-Agent Orchestration)": "keep",
|
|
"pi (alternative/complementary harness)": "replace", # tailored Hub version, full detail in README
|
|
"AWS Bedrock Authentication": "keep",
|
|
"MemPalace — persistent AI memory": "keep",
|
|
"Gitea MCP server": "keep",
|
|
"Context7 MCP server": "drop",
|
|
"Shell defaults": "drop", # detail, full README covers it
|
|
"Secret Scanning": "drop", # dev-only — gitleaks is for committers
|
|
"Architecture": "keep",
|
|
"License": "replace", # point at source repo instead
|
|
}
|
|
|
|
# Level-3 sub-section titles (without the leading "### ") to drop from
|
|
# sections flagged as "trim". These are dev/build-oriented — Docker Hub
|
|
# users already have the image and don't need rebuild or multi-user
|
|
# compose instructions.
|
|
TRIM_SUBSECTIONS: dict[str, set[str]] = {
|
|
"Configuration": {
|
|
"Multi-user setup",
|
|
"Rebuilding the Image",
|
|
"Build Args",
|
|
},
|
|
}
|
|
|
|
# Replacement bodies. Keys match SECTION_RULES entries marked "replace".
|
|
# Each value is the full section including the "## Title" heading.
|
|
REPLACEMENTS: dict[str, str] = {
|
|
"Quick Start": """## Quick Start
|
|
|
|
```bash
|
|
docker run -it --rm \\
|
|
-e ANTHROPIC_API_KEY=your-key \\
|
|
-e OPENCODE_PROVIDER=anthropic \\
|
|
-e GIT_USER_NAME="Your Name" \\
|
|
-e GIT_USER_EMAIL="you@example.com" \\
|
|
-v ~/projects:/workspace \\
|
|
-v ~/.ssh:/home/developer/.ssh:ro \\
|
|
joakimp/opencode-devbox:latest
|
|
```
|
|
|
|
This drops you straight into opencode with your project mounted at `/workspace`.
|
|
|
|
For an interactive shell first (useful for AWS SSO login):
|
|
|
|
```bash
|
|
docker run -it --rm \\
|
|
-e ANTHROPIC_API_KEY=your-key \\
|
|
-e OPENCODE_PROVIDER=anthropic \\
|
|
-v ~/projects:/workspace \\
|
|
-v ~/.ssh:/home/developer/.ssh:ro \\
|
|
joakimp/opencode-devbox:latest bash
|
|
```
|
|
|
|
Then run `opencode` when ready.
|
|
|
|
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
|
|
""",
|
|
"License": """## Source
|
|
|
|
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
|
""",
|
|
"pi (alternative/complementary harness)": """## pi (alternative/complementary harness)
|
|
|
|
[pi](https://github.com/mariozechner/pi-coding-agent) is a lightweight TUI coding-agent that can run alongside opencode in the same container. Both harnesses share the same mempalace install and palace data — wing/diary entries created by one are visible to the other. Available on the `*-with-pi` and `*-omos-with-pi` image tags.
|
|
|
|
### Run
|
|
|
|
The default container CMD is `bash -l`, so `compose run --rm devbox` drops to a login shell. From there pick a harness:
|
|
|
|
```bash
|
|
docker compose run --rm devbox # bash — then run `pi`, `opencode`, or `aws sso login`
|
|
docker compose run --rm devbox pi # launch pi directly
|
|
docker compose exec -u developer devbox pi # attach into a `compose up -d` container
|
|
```
|
|
|
|
### MemPalace integration
|
|
|
|
pi ships pre-wired with the mempalace bridge — the `mempalace.ts` extension is symlinked into `~/.pi/agent/extensions/` at first start, exposing palace MCP tools (search, diary, knowledge graph) to pi natively. The same `~/.mempalace/` palace is shared with opencode, so memories are mutually visible across harnesses. To persist palace data across container recreate, uncomment the `devbox-palace` volume in `docker-compose.yml` (see *MemPalace* section below).
|
|
|
|
### Persistence
|
|
|
|
| Path in container | Volume | Contains |
|
|
|---|---|---|
|
|
| `/home/developer/.pi` | `devbox-pi-config` (default) | `settings.json`, `agent/extensions/`, `auth.json`, `sessions/`, plus `git/` and `npm-global/` (`NPM_CONFIG_PREFIX` points here) so `pi install npm:` / `git:` and `npm i -g` survive container recreate and image rebuild. |
|
|
| `/home/developer/.mempalace` | `devbox-palace` (uncomment) | Shared palace — visible to pi and opencode |
|
|
|
|
Baked pi (`/usr/bin/pi`, `/opt/pi-*`) ships in the image; rebuild to upgrade. `npm i -g @mariozechner/pi-coding-agent` lands on the volume and wins via `PATH`.
|
|
|
|
Full build args, extension list, and toolkit detail: <https://gitea.jordbo.se/joakimp/opencode-devbox#pi-alternativecomplementary-harness>
|
|
""",
|
|
}
|
|
|
|
|
|
# Prepended to the generated file.
|
|
HEADER = """# opencode-devbox — Docker Hub
|
|
|
|
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
|
|
|
|
## Image Variants
|
|
|
|
Four image variants are published for each release:
|
|
|
|
| Tag | Description |
|
|
|---|---|
|
|
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
|
|
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
|
|
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/mariozechner/pi-coding-agent) as alternative/complementary harness (shares the mempalace install with opencode) |
|
|
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
|
|
|
|
All variants support `linux/amd64` and `linux/arm64`.
|
|
|
|
> **NOTE:** This file is auto-generated from `README.md` by `scripts/generate-dockerhub-md.py`. Edit README.md and regenerate rather than editing this file directly.
|
|
|
|
"""
|
|
|
|
|
|
def split_sections(md: str) -> list[tuple[str, str]]:
|
|
"""Split markdown on level-2 headings, returning (title, body) pairs.
|
|
|
|
The body includes the heading line and everything up to (but not
|
|
including) the next level-2 heading or EOF. Content before the first
|
|
``## `` is returned with an empty title (the document preamble).
|
|
"""
|
|
pattern = re.compile(r"^## ", re.MULTILINE)
|
|
parts = pattern.split(md)
|
|
preamble, *rest = parts
|
|
|
|
sections: list[tuple[str, str]] = []
|
|
if preamble.strip():
|
|
sections.append(("", preamble))
|
|
for part in rest:
|
|
line, _, body = part.partition("\n")
|
|
sections.append((line.strip(), f"## {line}\n{body}"))
|
|
return sections
|
|
|
|
|
|
def trim_subsections(body: str, drop: set[str]) -> str:
|
|
"""Remove level-3 sub-sections whose title is in `drop`.
|
|
|
|
A sub-section starts at a line beginning with "### " and ends at
|
|
the next "### " or "## " (or EOF).
|
|
"""
|
|
if not drop:
|
|
return body
|
|
|
|
# Split on level-3 headings while preserving the level-2 header
|
|
# block. First piece is everything up to the first "### ".
|
|
parts = re.split(r"(^### .+\n)", body, flags=re.MULTILINE)
|
|
# parts alternates: [before_first_h3, "### Title\n", body, "### Title\n", body, ...]
|
|
kept: list[str] = [parts[0]] if parts else []
|
|
i = 1
|
|
while i < len(parts):
|
|
heading = parts[i]
|
|
content = parts[i + 1] if i + 1 < len(parts) else ""
|
|
title = heading[4:].strip()
|
|
if title not in drop:
|
|
kept.append(heading)
|
|
kept.append(content)
|
|
i += 2
|
|
return "".join(kept)
|
|
|
|
|
|
def generate() -> str:
|
|
"""Produce the DOCKER_HUB.md content string."""
|
|
readme = README.read_text(encoding="utf-8")
|
|
sections = split_sections(readme)
|
|
|
|
out: list[str] = [HEADER]
|
|
unknown: list[str] = []
|
|
|
|
for title, body in sections:
|
|
if title == "":
|
|
# README preamble is replaced by our HEADER; skip.
|
|
continue
|
|
|
|
rule = SECTION_RULES.get(title)
|
|
if rule is None:
|
|
unknown.append(title)
|
|
continue
|
|
if rule == "drop":
|
|
continue
|
|
if rule == "keep":
|
|
out.append(body.rstrip() + "\n\n")
|
|
elif rule == "trim":
|
|
trimmed = trim_subsections(body, TRIM_SUBSECTIONS.get(title, set()))
|
|
out.append(trimmed.rstrip() + "\n\n")
|
|
elif rule == "replace":
|
|
out.append(REPLACEMENTS[title].rstrip() + "\n\n")
|
|
else: # pragma: no cover — programmer error
|
|
raise AssertionError(f"unknown rule {rule!r} for section {title!r}")
|
|
|
|
if unknown:
|
|
print(
|
|
"ERROR: README.md contains sections not classified in "
|
|
"SECTION_RULES:\n - "
|
|
+ "\n - ".join(unknown)
|
|
+ "\n\nAdd each to SECTION_RULES in "
|
|
"scripts/generate-dockerhub-md.py (choose keep/drop/replace).",
|
|
file=sys.stderr,
|
|
)
|
|
raise SystemExit(2)
|
|
|
|
return "".join(out).rstrip() + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="Fail if DOCKER_HUB.md differs from generated content.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
content = generate()
|
|
size = len(content.encode("utf-8"))
|
|
if size > MAX_SIZE_BYTES:
|
|
print(
|
|
f"ERROR: generated DOCKER_HUB.md is {size} bytes, exceeding the "
|
|
f"Docker Hub limit of {MAX_SIZE_BYTES} bytes.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
if args.check:
|
|
existing = DOCKER_HUB.read_text(encoding="utf-8") if DOCKER_HUB.exists() else ""
|
|
if existing != content:
|
|
print(
|
|
"ERROR: DOCKER_HUB.md is out of sync with README.md.\n"
|
|
"Run: python3 scripts/generate-dockerhub-md.py",
|
|
file=sys.stderr,
|
|
)
|
|
# Show a small diff hint.
|
|
import difflib
|
|
|
|
diff = difflib.unified_diff(
|
|
existing.splitlines(keepends=True),
|
|
content.splitlines(keepends=True),
|
|
fromfile="DOCKER_HUB.md (committed)",
|
|
tofile="DOCKER_HUB.md (generated)",
|
|
n=2,
|
|
)
|
|
sys.stderr.writelines(list(diff)[:80])
|
|
return 1
|
|
print(
|
|
f"OK: DOCKER_HUB.md is in sync with README.md "
|
|
f"({size} bytes, {MAX_SIZE_BYTES} limit).",
|
|
)
|
|
return 0
|
|
|
|
DOCKER_HUB.write_text(content, encoding="utf-8")
|
|
print(
|
|
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit).",
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|