oc2d bootstrap000%
oc2dv0.2.2
00:00:00

platform installer — systemd · caddy · let's encrypt

oc2d

One command installs OpenCode 2 behind HTTPS, wires the project manager in as an MCP server, and makes the agent in the chat the person who ships.

ubuntu@host — install live

      
$

01

The agent already had a chat box.
Now it has a deploy button.

OpenCode's web UI is the dashboard. The pm_* tools are the power behind it — and pmd is not just a daemon, it is Caddy's TLS authorization gate.

layer 01

OpenCode 2

Web UI on 127.0.0.1 only. You log in, you type, you ship.

layer 02

pm MCP server

Registered inside opencode.json. Nine tools, verified as connected — not just well-formed.

layer 03

pmd daemon

Health, routing, state.json, and the on-demand TLS gate.

systemd unit per project

unprivileged user pm, bound to loopback

caddy on_demand_tls

one certificate per subdomain, issued lazily

because pm is the whole gate, a stranger cannot burn your Let's Encrypt quota — and the installer tells pm to authorize the OpenCode site too, or its own certificate would stop issuing.

02

Everything that had to be
true, and is.

Each line below is a decision the installer makes before it touches your filesystem — and refuses to guess.

  1. 01

    The parent is never derived

    --domain is the one host serving OpenCode. pm's parent is a domain with a wildcard record, often somewhere else entirely. Resolution is --pm-parent → PM_PARENT → an existing *. block → you. Never guessed, because a guess becomes an install that fails on a record that cannot exist.

  2. 02

    A missing wildcard is not fatal

    When the records are absent the installer prints exactly what to add, how to re-run, and continues with exit 0. OpenCode 2 succeeded; pm is optional. Exit status only lies about the thing that must not be broken.

  3. 03

    HTTPS checks are non-fatal on purpose

    With on-demand TLS, Caddy issues lazily — seconds after install there is legitimately no certificate. The installer nudges the HTTP-01 challenge, then retries for up to ~120 seconds and reports what it is still waiting for.

  4. 04

    Smart merge into your Caddyfile

    Your existing /etc/caddy/Caddyfile is backed up to a timestamped .bak and merged into — never overwritten — validated before reload, rolled back on failure. Pre-existing sites are probed afterwards to prove they were not broken.

  5. 05

    A refusing gate cannot be ignored

    Mirroring an existing on-demand wildcard policy silently can produce a config where the gate would never issue. The installer probes the ask endpoint and refuses to continue on a policy conflict, explaining both remedies.

  6. 06

    Root commands run exactly once

    sudo -n "$@" || sudo "$@" cannot tell "needs a password" from "exited non-zero", so every failing root command used to run a second time — including destructive ones. The retry now happens only on genuine authentication failure.

  7. 07

    Reboot is the default, not a hope

    Both units are enabled, linger is enabled, Restart=always, RestartSec=5, and StartLimitIntervalSec=0 so systemd can never rate-limit a crash loop into a permanently dead unit.

  8. 08

    Uninstall that actually undoes it

    --uninstall --with-pm stops and deletes pmd.service and every project unit, drops the pm entry while leaving other MCP servers intact, and restores the on_demand_tls gate pm had taken over.

03

Nine tools.
Typed like an API.

This is the whole surface the agent gets. Copy any line and paste it into the chat.

tooleffect
pm_create({ name, subdomain? })scaffold, start, route, wait for HTTPS
pm_list()every project with status, port, subdomain
pm_status({ name })one project's state
pm_start({ name })start a project
pm_stop({ name })stop a project, keep its files
pm_restart({ name })restart its systemd unit
pm_delete({ name, purge? })remove unit + route; purge deletes files
pm_logs({ name, lines? })recent journal output

then, in the chat — nothing else to learn:

04

The boring part,
done properly.

Password handling is the part most installers get wrong. This one has a script for it, a rollback, and a grammar that counts characters instead of bytes.

binding

OpenCode binds to 127.0.0.1 only. Caddy is the sole public-facing entry point, and the installer actively verifies the backend is not bound to a public address.

storage

The password lives in ~/.config/opencode/env, mode 0600 inside a 0700 directory, and reaches the unit through EnvironmentFile — so it never appears in systemctl show output.

input

Interactive entry uses read -s: not echoed, never in your shell history. A password you typed is never printed back. Only an auto-generated one is shown, and only once.

rollback

./change-password.sh stops the service, rewrites the single OPENCODE_PASSWORD= line — every other line preserved — and restarts. If it does not come back, the old password is restored automatically. A failed change can never lock you out.

counting

Length is counted in characters, not bytes. One Arabic character is 2 bytes, so a byte count would report a strong password as "shorter than 12" — the bug that made the warning unreadable.

escapes

systemd's EnvironmentFile treats a backslash as an escape and trims edge whitespace — so those would be stored but never received as typed. Both scripts warn.

rejected outright, on every path — including --password

  • password
  • 123456
  • admin
  • opencode
  • changeme
  • letmein
  • qwerty

awaiting input

    05

    Run it as
    your normal user.

    Not root. The OpenCode service must read your own config and credentials.

    oc2d
    $ ./install-opencode.sh --domain example.com
    

    the flags worth knowing

    --domain <d>
    required
    the host serving OpenCode over HTTPS
    --pm-parent <d>
    detected, else prompted
    where pm may hand out subdomains — never derived
    --port <n>
    4096
    internal port; busy → auto-shifts
    --strict-port
    off
    abort on a busy port instead of shifting
    --generate-password
    off
    skip the prompt, generate a random one
    --without-pm
    auto
    never install pm; OpenCode still installs
    --skip-dns-check
    off
    proceed even if the A record misses
    --uninstall
     
    remove the unit and this domain's block

    it ends with a tally

    [OK]0
    [WARN]0
    [FAIL]0

    Only fatal failures abort the install. Fatal: the unit active, the backend answering on loopback, Caddy active, both units enabled at boot, linger enabled, and the backend not publicly bound. Certificate timing is a warning, never a failure.

    Fatal checks pass on the backend's loopback health, never on HTTPS — so a slow ACME round-trip cannot misreport a healthy install.

    06

    Notes from
    a fresh host.

    Every entry below was a bug reported from someone's own machine, fixed in a released version.

    0.2.2

    Two regressions, both from a fresh install

    /srv/pm was chowned before it existed — useradd --home-dir does not create the directory, so mkdir -p now precedes the chown.

    The parent prompt used to echo --domain into itself, so pressing Enter accepted a parent you never chose. The prompt is now empty and says plainly that the parent is independent.

    Pressing Enter could no longer skip pm. An empty answer now prints Skipping pm install. and continues — because an empty answer is a decision, not a typo.

    0.2.1

    The parent domain was never the right question

    --domain oc2d.example.com produced parent example.com — a domain nobody configured — and the install then failed a wildcard check for a record that could not exist. Resolution is now explicit, in a fixed order, and never guessed.

    grep -q "^[[:space:]]*\*.$P" matched nothing: the unescaped . and * made it a regex that never hit, so pm appended a second wildcard and Caddy rejected the whole file with ambiguous site definition. Matching is now -F.

    0.2.0

    One installer, both halves

    pm_create, pm_list, pm_logs and the rest arrived as tools the agent calls; install-pm.sh grew --allow-domain for the unified path.

    After writing the config the installer restarts OpenCode and polls /api/mcp until it reports pm as connected. A well-formed config file no longer counts as proof.

    suite

    89 + 7 assertions, and a mock network

    pm-test/ ships fake systemctl, journalctl and services, plus a fake DNS + TLS pair, so the whole suite runs locally without touching a real host or burning a single certificate.

    ٠٧ — بالعربي

    الخلاصة

    oc2d يثبّت لك OpenCode 2 خلف HTTPS مع Caddy، ويوصّل مدير المشاريع (pm) جوّه OpenCode كـ MCP server. يعني بدل ما تفتح لوحة تحكم وتعمل إعدادات، تكتب بال-chat العادي:

    «أنشئ مشروع اسمه portfolio على portfolio.example.com»

    فيقوم pm يشغّل الخدمة، يعطيه بورت، يربطه على subdomain، ويجيب له شهادة SSL صالحة — كله تلقائياً.

    النقطة الأهم: pmd نفسه هو بوابة تفويض شهادات Caddy، فحدا ما يقدر يستهلك حصة Let's Encrypt بتاعتك. وكل قرار الم installer بياخده بيكون صريح — ما بيخمّن، وبيفحص قبل ما يمس أي ملف.