From eed9df5d2bf3e63242f5c239fb73336356867b17 Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 14:18:22 -0400 Subject: [PATCH 01/11] docs: spec for dokku version check --- .../2026-04-28-dokku-version-check-design.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-28-dokku-version-check-design.md diff --git a/docs/superpowers/specs/2026-04-28-dokku-version-check-design.md b/docs/superpowers/specs/2026-04-28-dokku-version-check-design.md new file mode 100644 index 0000000..a238649 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-dokku-version-check-design.md @@ -0,0 +1,140 @@ +# Dokku version check + +## Summary + +Wire up a runtime check for the existing `dokku.version` field in +`dokku-compose.yml`. When the user has pinned a version, compare it to the +server's reported version on `up` and `diff`. If the server is older than the +pinned floor, emit a warning. If the server's version cannot be parsed, raise +an error. Absent field is silent — today's behavior is preserved by default. + +## Motivation + +`dokku.version` is already in the schema (`src/core/schema.ts:99-101`) and is +written by `export` (`src/commands/export.ts:18-20`), but nothing reads it on +apply. Users round-tripping `export` → edit → `up` get a snapshot of the +server's version embedded in their config with no feedback if they later run +against an older or newer server. A minimum-version check turns that snapshot +into a meaningful floor without forcing exact-version churn on every patch +release. + +## Behavior + +### Semantics + +- The pinned `dokku.version` value is interpreted as **minimum required**. + Server version ≥ pinned is silent. Server version < pinned emits a warning. +- Comparison is numeric `MAJOR.MINOR.PATCH`. Pre-release suffixes + (`-rc1`, `-beta`) on the server output are tolerated and ignored for the + comparison. +- Field absent → silent (no warning, no error). The check is opt-in. + +### Where the check runs + +- `up` — runs once at the start of orchestration, before plugins. Warning + is non-blocking; orchestration proceeds. +- `diff` — surfaces version drift in the summary output when pinned and + mismatched. +- `validate` — **does not** run the check. `validate` is offline today and + must stay offline; adding a server query would change its contract. +- `export` — unchanged. Continues to write the server's reported version + into the emitted config. + +### Error vs. warning + +| Condition | Outcome | +|------------------------------------|----------| +| Field absent | Silent | +| Server version ≥ pinned | Silent | +| Server version < pinned | Warning | +| `dokku version` output unparseable | Error | + +Warning text: + +``` +Dokku server is v0.36.4 but dokku-compose.yml pins >= v0.37.9. Some features may be unavailable. +``` + +Rationale for unparseable → error: a `dokku version` command that returns +something we can't recognise as semver indicates the runner is talking to +something that isn't Dokku, or Dokku has changed its output format. Either +way, silently continuing risks misleading downstream behavior. + +## Architecture + +### New module: `src/modules/version.ts` + +Exports two functions, one pure and one runner-aware: + +```ts +// Pure helper — easy to unit-test without mocks. +// Returns -1 if a < b, 0 if equal, 1 if a > b. +// Throws if either input is not parseable as MAJOR.MINOR.PATCH. +export function compareSemver(a: string, b: string): -1 | 0 | 1 + +// Runner-aware orchestration entry point. +// No-op if `pinned` is undefined. +// Queries `dokku version`, parses, compares, logs. +// Throws on unparseable server output. +export async function ensureDokkuVersion( + runner: DokkuRunner, + pinned: string | undefined, +): Promise +``` + +Parsing regex: `/(\d+)\.(\d+)\.(\d+)/`. This is intentionally identical to +the regex already used in `src/commands/export.ts:19` so the two sites stay +in lockstep — if one ever needs to handle a new format, both do. + +### Wire-up + +- `src/commands/up.ts` — call `ensureDokkuVersion(ctx, config.dokku?.version)` + at the top of orchestration, before plugin installation. Warnings emit via + `logWarn('dokku', ...)` from `src/core/logger.ts`. +- `src/commands/diff.ts` — include a "Dokku version" entry in the summary + output when `config.dokku?.version` is set and the server version is + lower. Quiet when matching or higher. + +### No schema change + +`ConfigSchema.dokku.version` already exists as `z.string().optional()`. No +migration, no version bump of the config format. + +## Testing + +### `src/modules/version.test.ts` + +`compareSemver` — table-driven: + +| `a` | `b` | expected | +|-------------|-------------|----------| +| `0.37.9` | `0.37.9` | `0` | +| `0.37.9` | `0.37.10` | `-1` | +| `0.38.0` | `0.37.99` | `1` | +| `1.0.0` | `0.99.99` | `1` | +| `0.37.9-rc1`| `0.37.9` | `0` | (pre-release suffix ignored) +| `garbage` | `0.37.9` | throws | + +`ensureDokkuVersion` — with mocked `runner.query`: + +- Returns silently when `pinned` is `undefined` (no query made). +- Returns silently when server ≥ pinned. +- Logs a warning when server < pinned. Asserts the message includes both + versions. +- Throws when server output does not contain `X.Y.Z`. + +### Integration + +No new fixture YAML required — `dokku.version` already round-trips through +the existing schema. + +## Out of scope + +- `max_version` / range constraints. Add when there is a known-breaking + upper bound to enforce. Until then, YAGNI. +- Semver constraint strings (`^`, `>=`, etc.). The minimum-version + semantic covers the realistic use case without a parser dependency. +- Pinning Dokku plugin versions against the server. Plugins already accept + a `version` field per plugin entry; that's a separate concern. +- A `--strict` flag to escalate the warning to an error. Easy to add later + if needed; not required for the initial behavior. From 5f8f6e54306e4897283cbc9c3a07a7f9c96d978b Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 14:23:47 -0400 Subject: [PATCH 02/11] docs: implementation plan for dokku version check --- .../plans/2026-04-28-dokku-version-check.md | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-28-dokku-version-check.md diff --git a/docs/superpowers/plans/2026-04-28-dokku-version-check.md b/docs/superpowers/plans/2026-04-28-dokku-version-check.md new file mode 100644 index 0000000..fdeb1ba --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-dokku-version-check.md @@ -0,0 +1,441 @@ +# Dokku Version Check Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire up a runtime check for the existing `dokku.version` field so that `up` and `diff` warn when the server's Dokku version is older than what the user pinned. + +**Architecture:** A new module `src/modules/version.ts` exposes a pure semver comparator (`compareSemver`) and a runner-aware orchestration function (`ensureDokkuVersion`). The orchestration function is called once at the start of both `runUp` and `computeDiff`. Server output is parsed with the same regex `export.ts` already uses. Behavior is opt-in: silent when `dokku.version` is absent. + +**Tech Stack:** TypeScript (strict), Vitest (mocking via `vi.fn()`), the existing `Context` and chalk-based logger. + +**Spec:** `docs/superpowers/specs/2026-04-28-dokku-version-check-design.md` + +> **Note on spec:** the spec sketches the function signature as `(runner: DokkuRunner, ...)` informally. The actual codebase passes `Context` to module functions (see `src/modules/plugins.ts` etc.). This plan uses `Context` — that is the correct type. + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/modules/version.ts` | Create | `compareSemver` (pure) + `ensureDokkuVersion(ctx, pinned)` | +| `src/modules/version.test.ts` | Create | Unit tests for both exports | +| `src/commands/up.ts` | Modify | Call `ensureDokkuVersion` before plugins | +| `src/commands/diff.ts` | Modify | Call `ensureDokkuVersion` before prefetch | +| `docs/reference/dokku.md` | Create | User-facing reference for the `dokku.version` key | +| `README.md` | Modify | Add a row to the Features table linking to `dokku.md` | + +--- + +## Task 1: `compareSemver` pure helper + +**Files:** +- Create: `src/modules/version.ts` +- Create: `src/modules/version.test.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `src/modules/version.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { compareSemver } from './version.js' + +describe('compareSemver', () => { + it('returns 0 for equal versions', () => { + expect(compareSemver('0.37.9', '0.37.9')).toBe(0) + }) + + it('returns -1 when a is older (patch)', () => { + expect(compareSemver('0.37.9', '0.37.10')).toBe(-1) + }) + + it('returns 1 when a is newer (minor)', () => { + expect(compareSemver('0.38.0', '0.37.99')).toBe(1) + }) + + it('returns 1 when a is newer (major)', () => { + expect(compareSemver('1.0.0', '0.99.99')).toBe(1) + }) + + it('ignores pre-release suffix when otherwise equal', () => { + expect(compareSemver('0.37.9-rc1', '0.37.9')).toBe(0) + }) + + it('ignores pre-release suffix on both sides', () => { + expect(compareSemver('0.37.9-rc1', '0.37.9-rc2')).toBe(0) + }) + + it('throws when input is not parseable', () => { + expect(() => compareSemver('garbage', '0.37.9')).toThrow(/parse/i) + }) + + it('throws when only major.minor (missing patch)', () => { + expect(() => compareSemver('0.37', '0.37.9')).toThrow(/parse/i) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/modules/version.test.ts` +Expected: FAIL — `Cannot find module './version.js'` (or similar import error). + +- [ ] **Step 3: Implement `compareSemver`** + +Create `src/modules/version.ts`: + +```typescript +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/ + +function parseSemver(input: string): [number, number, number] { + const m = input.match(SEMVER_RE) + if (!m) throw new Error(`Cannot parse version: ${input}`) + return [Number(m[1]), Number(m[2]), Number(m[3])] +} + +export function compareSemver(a: string, b: string): -1 | 0 | 1 { + const [aMaj, aMin, aPatch] = parseSemver(a) + const [bMaj, bMin, bPatch] = parseSemver(b) + if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1 + if (aMin !== bMin) return aMin < bMin ? -1 : 1 + if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1 + return 0 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/modules/version.test.ts` +Expected: PASS — 8 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/modules/version.ts src/modules/version.test.ts +git commit -m "feat: add compareSemver helper" +``` + +--- + +## Task 2: `ensureDokkuVersion` orchestration function + +**Files:** +- Modify: `src/modules/version.ts` +- Modify: `src/modules/version.test.ts` + +- [ ] **Step 1: Add failing tests for `ensureDokkuVersion`** + +Append to `src/modules/version.test.ts`: + +```typescript +import { vi } from 'vitest' +import { createRunner } from '../core/dokku.js' +import { createContext } from '../core/context.js' +import { ensureDokkuVersion } from './version.js' + +describe('ensureDokkuVersion', () => { + it('does nothing when pinned is undefined (no query)', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn() + const ctx = createContext(runner) + await ensureDokkuVersion(ctx, undefined) + expect(runner.query).not.toHaveBeenCalled() + }) + + it('is silent when server version equals pinned', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn().mockResolvedValue('dokku version 0.37.9') + const ctx = createContext(runner) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await ensureDokkuVersion(ctx, '0.37.9') + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('is silent when server version is newer than pinned', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn().mockResolvedValue('0.38.0') + const ctx = createContext(runner) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await ensureDokkuVersion(ctx, '0.37.9') + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('warns when server version is older than pinned', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn().mockResolvedValue('dokku version 0.36.4') + const ctx = createContext(runner) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await ensureDokkuVersion(ctx, '0.37.9') + expect(warnSpy).toHaveBeenCalledTimes(1) + const msg = warnSpy.mock.calls[0][0] as string + expect(msg).toContain('0.36.4') + expect(msg).toContain('0.37.9') + warnSpy.mockRestore() + }) + + it('throws when server output is unparseable', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn().mockResolvedValue('something weird') + const ctx = createContext(runner) + await expect(ensureDokkuVersion(ctx, '0.37.9')).rejects.toThrow(/parse/i) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/modules/version.test.ts` +Expected: FAIL — `ensureDokkuVersion is not exported from './version.js'`. + +- [ ] **Step 3: Implement `ensureDokkuVersion`** + +Append to `src/modules/version.ts`: + +```typescript +import type { Context } from '../core/context.js' +import { logWarn } from '../core/logger.js' + +export async function ensureDokkuVersion( + ctx: Context, + pinned: string | undefined +): Promise { + if (!pinned) return + + const output = await ctx.query('version') + const match = output.match(/(\d+\.\d+\.\d+)/) + if (!match) { + throw new Error(`Cannot parse Dokku server version from output: ${output}`) + } + const server = match[1] + + if (compareSemver(server, pinned) === -1) { + logWarn( + 'dokku', + `server is v${server} but dokku-compose.yml pins >= v${pinned}. Some features may be unavailable.` + ) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/modules/version.test.ts` +Expected: PASS — all tests in the file passing (5 new + 8 from Task 1 = 13). + +- [ ] **Step 5: Commit** + +```bash +git add src/modules/version.ts src/modules/version.test.ts +git commit -m "feat: add ensureDokkuVersion check" +``` + +--- + +## Task 3: Wire into `up` + +**Files:** +- Modify: `src/commands/up.ts:35-36` (insert before "Phase 1: Plugins") + +- [ ] **Step 1: Add the import and call** + +Edit `src/commands/up.ts`. Add this import next to the other module imports (around line 15): + +```typescript +import { ensureDokkuVersion } from '../modules/version.js' +``` + +Then insert the version check at the top of the orchestration body, before "Phase 1: Plugins". The existing block: + +```typescript + const apps = appFilter.length > 0 + ? appFilter + : Object.keys(config.apps) + + // Phase 1: Plugins & host-level auth + if (config.plugins) await ensurePlugins(ctx, config.plugins) +``` + +becomes: + +```typescript + const apps = appFilter.length > 0 + ? appFilter + : Object.keys(config.apps) + + // Phase 0: Version check (warning-only, opt-in via dokku.version) + await ensureDokkuVersion(ctx, config.dokku?.version) + + // Phase 1: Plugins & host-level auth + if (config.plugins) await ensurePlugins(ctx, config.plugins) +``` + +- [ ] **Step 2: Run the full test suite** + +Run: `bun test` +Expected: PASS — all existing tests still passing, no regressions. + +- [ ] **Step 3: Smoke test in dry-run** + +Run: `./bin/dokku-compose up --dry-run` against a fixture or local config that does NOT pin `dokku.version`. +Expected: no warning, no error, dry-run output as before. + +If a `DOKKU_HOST` and a fixture with `dokku.version` set higher than the server are available, run again and confirm the warning prints. (Skip this sub-step if no test server is reachable — the unit tests cover the logic.) + +- [ ] **Step 4: Commit** + +```bash +git add src/commands/up.ts +git commit -m "feat: check dokku version on up" +``` + +--- + +## Task 4: Wire into `diff` + +**Files:** +- Modify: `src/commands/diff.ts:26-30` (insert before bulk prefetch) + +- [ ] **Step 1: Add the import and call** + +Edit `src/commands/diff.ts`. Add this import next to the existing imports (around line 4): + +```typescript +import { ensureDokkuVersion } from '../modules/version.js' +``` + +Then insert the version check at the top of `computeDiff`, before the bulk prefetch. The existing block: + +```typescript +export async function computeDiff(ctx: Context, config: Config): Promise { + const result: DiffResult = { apps: {}, services: {}, inSync: true } + + // Bulk prefetch: run all readAll queries in parallel +``` + +becomes: + +```typescript +export async function computeDiff(ctx: Context, config: Config): Promise { + const result: DiffResult = { apps: {}, services: {}, inSync: true } + + // Version check (warning-only, opt-in via dokku.version) + await ensureDokkuVersion(ctx, config.dokku?.version) + + // Bulk prefetch: run all readAll queries in parallel +``` + +- [ ] **Step 2: Run the full test suite** + +Run: `bun test` +Expected: PASS — no regressions. + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/diff.ts +git commit -m "feat: check dokku version on diff" +``` + +--- + +## Task 5: Reference docs + README link + +**Files:** +- Create: `docs/reference/dokku.md` +- Modify: `README.md:107-130` (Features table) + +- [ ] **Step 1: Create the reference doc** + +Create `docs/reference/dokku.md` with exactly this content (a four-backtick fence is used here so the inner three-backtick yaml fence is shown verbatim — the file itself uses normal three-backtick fences): + +````markdown +# Dokku Version + +Dokku docs: https://dokku.com/docs/getting-started/upgrading/ + +Module: `src/modules/version.ts` + +## YAML Keys + +### Pinned Version (`dokku.version`) + +Declare the minimum Dokku server version your config requires. On `up` and +`diff`, dokku-compose queries the server with `dokku version` and warns if +the server is older than the pinned value. The check is opt-in — if the key +is absent, no check runs. + +The value is interpreted as a **minimum**, not an exact match: a server +running a newer version than pinned is silent. Pre-release suffixes +(`-rc1`, `-beta`) on the server output are tolerated. + +The `export` command writes the running server's version into this field +automatically, so round-tripping `export` → edit → `up` produces a useful +floor without manual effort. + +```yaml +dokku: + version: "0.37.9" # warn if server is < 0.37.9 +``` + +| Value | Behavior | +|-------|----------| +| `"X.Y.Z"` | `dokku version` (query)
warn if server version `< X.Y.Z` | +| absent | no action | + +If the server's `dokku version` output cannot be parsed as `X.Y.Z`, +dokku-compose raises an error rather than continuing silently — that +indicates either the runner is talking to something that isn't Dokku, or +Dokku has changed its output format. +```` + +- [ ] **Step 2: Add the row to the README Features table** + +Edit `README.md`. The Features table currently ends with the Service Links row at line 130. Add a new row above the table's first data row (alphabetical-ish placement isn't strict in this table; group it with infrastructure-level items near the top). Insert this row immediately after the `| Apps | ... |` row: + +```markdown +| Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) | +``` + +The relevant section after the edit: + +```markdown +| Feature | Description | Reference | +|---------|-------------|-----------| +| Apps | Create and destroy Dokku apps | [apps](docs/reference/apps.md) | +| Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) | +| Environment Variables | Set config vars per app or globally, with full convergence | [config](docs/reference/config.md) | +``` + +- [ ] **Step 3: Verify links resolve** + +Run: `ls docs/reference/dokku.md && grep -F "docs/reference/dokku.md" README.md` +Expected: file listed, README contains the link. + +- [ ] **Step 4: Commit** + +```bash +git add docs/reference/dokku.md README.md +git commit -m "docs: document dokku.version field" +``` + +--- + +## Final Verification + +- [ ] **Step 1: Full test suite** + +Run: `bun test` +Expected: PASS — all tests including the 13 new ones in `version.test.ts`. + +- [ ] **Step 2: Build** + +Run: `bun run build` +Expected: PASS — TypeScript strict mode compiles clean. + +- [ ] **Step 3: Smoke test (offline)** + +Run: `./bin/dokku-compose validate src/tests/fixtures/.yml` +Expected: no errors. (Validate is offline — version check should not run here.) From 37a57be858d3e487a3d951d6f70957e6881ebda5 Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 14:28:25 -0400 Subject: [PATCH 03/11] docs: tighten version-check plan after readability review Address gaps surfaced by a junior-developer review pass: - Add Prerequisites covering bun/vitest and Context vs Runner types - Show full final test and module file contents instead of "append" steps - Make up.ts insertion explicit so existing docker_auth line is preserved - Resolve conflicting README placement instruction - Specify validate command shape and a real fixture path --- .../plans/2026-04-28-dokku-version-check.md | 103 +++++++++++++++--- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/plans/2026-04-28-dokku-version-check.md b/docs/superpowers/plans/2026-04-28-dokku-version-check.md index fdeb1ba..d20a61f 100644 --- a/docs/superpowers/plans/2026-04-28-dokku-version-check.md +++ b/docs/superpowers/plans/2026-04-28-dokku-version-check.md @@ -27,6 +27,14 @@ --- +## Prerequisites + +- Run `bun install` from the repo root if you haven't already. +- This project uses **Vitest** as the test framework, but tests are invoked through Bun's built-in test runner via `bun test `. No separate Vitest setup is needed — `bun test` discovers Vitest automatically. +- The `Context` type (passed to module functions) is defined at `src/core/context.ts:3`. The `Runner` type (used inside tests via `createRunner`) is at `src/core/dokku.ts:11`. Test files create a runner, override its methods with `vi.fn()`, then wrap with `createContext(runner)` — see `src/modules/plugins.test.ts` for the canonical pattern. + +--- + ## Task 1: `compareSemver` pure helper **Files:** @@ -124,15 +132,51 @@ git commit -m "feat: add compareSemver helper" - Modify: `src/modules/version.ts` - Modify: `src/modules/version.test.ts` -- [ ] **Step 1: Add failing tests for `ensureDokkuVersion`** +- [ ] **Step 1: Replace `src/modules/version.test.ts` with the complete final test file** -Append to `src/modules/version.test.ts`: +This step replaces the file from Task 1 with the full final version (existing tests + new tests). Imports stay grouped at the top of the file as is the project convention. Note that `warnSpy.mock.calls[0][0]` will contain ANSI color codes (chalk wraps the string in yellow); `toContain('0.36.4')` still works because the digits appear verbatim in the formatted output. + +Replace the contents of `src/modules/version.test.ts` with: ```typescript -import { vi } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { createRunner } from '../core/dokku.js' import { createContext } from '../core/context.js' -import { ensureDokkuVersion } from './version.js' +import { compareSemver, ensureDokkuVersion } from './version.js' + +describe('compareSemver', () => { + it('returns 0 for equal versions', () => { + expect(compareSemver('0.37.9', '0.37.9')).toBe(0) + }) + + it('returns -1 when a is older (patch)', () => { + expect(compareSemver('0.37.9', '0.37.10')).toBe(-1) + }) + + it('returns 1 when a is newer (minor)', () => { + expect(compareSemver('0.38.0', '0.37.99')).toBe(1) + }) + + it('returns 1 when a is newer (major)', () => { + expect(compareSemver('1.0.0', '0.99.99')).toBe(1) + }) + + it('ignores pre-release suffix when otherwise equal', () => { + expect(compareSemver('0.37.9-rc1', '0.37.9')).toBe(0) + }) + + it('ignores pre-release suffix on both sides', () => { + expect(compareSemver('0.37.9-rc1', '0.37.9-rc2')).toBe(0) + }) + + it('throws when input is not parseable', () => { + expect(() => compareSemver('garbage', '0.37.9')).toThrow(/parse/i) + }) + + it('throws when only major.minor (missing patch)', () => { + expect(() => compareSemver('0.37', '0.37.9')).toThrow(/parse/i) + }) +}) describe('ensureDokkuVersion', () => { it('does nothing when pinned is undefined (no query)', async () => { @@ -190,14 +234,31 @@ describe('ensureDokkuVersion', () => { Run: `bun test src/modules/version.test.ts` Expected: FAIL — `ensureDokkuVersion is not exported from './version.js'`. -- [ ] **Step 3: Implement `ensureDokkuVersion`** +- [ ] **Step 3: Replace `src/modules/version.ts` with the complete final module file** -Append to `src/modules/version.ts`: +Imports go at the top (project convention). Replace the contents of `src/modules/version.ts` with: ```typescript import type { Context } from '../core/context.js' import { logWarn } from '../core/logger.js' +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/ + +function parseSemver(input: string): [number, number, number] { + const m = input.match(SEMVER_RE) + if (!m) throw new Error(`Cannot parse version: ${input}`) + return [Number(m[1]), Number(m[2]), Number(m[3])] +} + +export function compareSemver(a: string, b: string): -1 | 0 | 1 { + const [aMaj, aMin, aPatch] = parseSemver(a) + const [bMaj, bMin, bPatch] = parseSemver(b) + if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1 + if (aMin !== bMin) return aMin < bMin ? -1 : 1 + if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1 + return 0 +} + export async function ensureDokkuVersion( ctx: Context, pinned: string | undefined @@ -239,15 +300,17 @@ git commit -m "feat: add ensureDokkuVersion check" **Files:** - Modify: `src/commands/up.ts:35-36` (insert before "Phase 1: Plugins") -- [ ] **Step 1: Add the import and call** +- [ ] **Step 1a: Add the import** -Edit `src/commands/up.ts`. Add this import next to the other module imports (around line 15): +In `src/commands/up.ts`, add this line to the module-imports block (the `import { ensure* } from '../modules/*.js'` cluster around lines 15-24). Alphabetical order suggests placing it between `ensureRedis` and `ensureAppLinks` is fine — exact placement isn't enforced: ```typescript import { ensureDokkuVersion } from '../modules/version.js' ``` -Then insert the version check at the top of the orchestration body, before "Phase 1: Plugins". The existing block: +- [ ] **Step 1b: Insert the version-check call before Phase 1** + +The current `runUp` body has this structure (verified against `src/commands/up.ts:31-37`): ```typescript const apps = appFilter.length > 0 @@ -256,9 +319,10 @@ Then insert the version check at the top of the orchestration body, before "Phas // Phase 1: Plugins & host-level auth if (config.plugins) await ensurePlugins(ctx, config.plugins) + if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth) ``` -becomes: +**Insert two new lines (a comment and the call) immediately before the `// Phase 1:` comment line. Do NOT modify or remove any existing lines.** The result should look like: ```typescript const apps = appFilter.length > 0 @@ -270,6 +334,7 @@ becomes: // Phase 1: Plugins & host-level auth if (config.plugins) await ensurePlugins(ctx, config.plugins) + if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth) ``` - [ ] **Step 2: Run the full test suite** @@ -277,12 +342,12 @@ becomes: Run: `bun test` Expected: PASS — all existing tests still passing, no regressions. -- [ ] **Step 3: Smoke test in dry-run** +- [ ] **Step 3: Build to confirm TypeScript compiles** -Run: `./bin/dokku-compose up --dry-run` against a fixture or local config that does NOT pin `dokku.version`. -Expected: no warning, no error, dry-run output as before. +Run: `bun run build` +Expected: PASS — no TypeScript errors. -If a `DOKKU_HOST` and a fixture with `dokku.version` set higher than the server are available, run again and confirm the warning prints. (Skip this sub-step if no test server is reachable — the unit tests cover the logic.) +(End-to-end smoke testing requires a `DOKKU_HOST`. The unit tests in Task 2 fully cover the runtime logic, and the build verifies the wiring compiles. If you have a server available, you can additionally run `DOKKU_HOST= ./bin/dokku-compose up -f src/tests/fixtures/simple.yml --dry-run` — the fixture does not pin `dokku.version`, so no warning should appear.) - [ ] **Step 4: Commit** @@ -393,13 +458,13 @@ Dokku has changed its output format. - [ ] **Step 2: Add the row to the README Features table** -Edit `README.md`. The Features table currently ends with the Service Links row at line 130. Add a new row above the table's first data row (alphabetical-ish placement isn't strict in this table; group it with infrastructure-level items near the top). Insert this row immediately after the `| Apps | ... |` row: +In `README.md`, find the Features table (around lines 111-130). Insert one new row immediately after the `| Apps | ... |` row and before the `| Environment Variables | ... |` row. The new row is: ```markdown | Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) | ``` -The relevant section after the edit: +After the edit, the top of the table should read exactly: ```markdown | Feature | Description | Reference | @@ -437,5 +502,7 @@ Expected: PASS — TypeScript strict mode compiles clean. - [ ] **Step 3: Smoke test (offline)** -Run: `./bin/dokku-compose validate src/tests/fixtures/.yml` -Expected: no errors. (Validate is offline — version check should not run here.) +`validate` takes a positional file argument (see `src/index.ts:73-75`). + +Run: `./bin/dokku-compose validate src/tests/fixtures/simple.yml` +Expected: no errors and no warning about Dokku version. `validate` runs offline and never queries the server, so the version check must not fire here — that confirms the wiring (Tasks 3 and 4) is in `up`/`diff` only. From 9fa0c650a185c2bca4fb5750fa1d67306b3b9dc4 Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 15:35:25 -0400 Subject: [PATCH 04/11] feat: add compareSemver helper --- src/modules/version.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/modules/version.ts | 16 ++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/modules/version.test.ts create mode 100644 src/modules/version.ts diff --git a/src/modules/version.test.ts b/src/modules/version.test.ts new file mode 100644 index 0000000..266c2ae --- /dev/null +++ b/src/modules/version.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { compareSemver } from './version.js' + +describe('compareSemver', () => { + it('returns 0 for equal versions', () => { + expect(compareSemver('0.37.9', '0.37.9')).toBe(0) + }) + + it('returns -1 when a is older (patch)', () => { + expect(compareSemver('0.37.9', '0.37.10')).toBe(-1) + }) + + it('returns 1 when a is newer (minor)', () => { + expect(compareSemver('0.38.0', '0.37.99')).toBe(1) + }) + + it('returns 1 when a is newer (major)', () => { + expect(compareSemver('1.0.0', '0.99.99')).toBe(1) + }) + + it('ignores pre-release suffix when otherwise equal', () => { + expect(compareSemver('0.37.9-rc1', '0.37.9')).toBe(0) + }) + + it('ignores pre-release suffix on both sides', () => { + expect(compareSemver('0.37.9-rc1', '0.37.9-rc2')).toBe(0) + }) + + it('throws when input is not parseable', () => { + expect(() => compareSemver('garbage', '0.37.9')).toThrow(/parse/i) + }) + + it('throws when only major.minor (missing patch)', () => { + expect(() => compareSemver('0.37', '0.37.9')).toThrow(/parse/i) + }) +}) diff --git a/src/modules/version.ts b/src/modules/version.ts new file mode 100644 index 0000000..d32d919 --- /dev/null +++ b/src/modules/version.ts @@ -0,0 +1,16 @@ +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/ + +function parseSemver(input: string): [number, number, number] { + const m = input.match(SEMVER_RE) + if (!m) throw new Error(`Cannot parse version: ${input}`) + return [Number(m[1]), Number(m[2]), Number(m[3])] +} + +export function compareSemver(a: string, b: string): -1 | 0 | 1 { + const [aMaj, aMin, aPatch] = parseSemver(a) + const [bMaj, bMin, bPatch] = parseSemver(b) + if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1 + if (aMin !== bMin) return aMin < bMin ? -1 : 1 + if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1 + return 0 +} From 9b8cebd1adf4d500c624e3d761b9d825601f98c5 Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 15:38:53 -0400 Subject: [PATCH 05/11] feat: add ensureDokkuVersion check --- src/modules/version.test.ts | 56 +++++++++++++++++++++++++++++++++++-- src/modules/version.ts | 24 ++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/modules/version.test.ts b/src/modules/version.test.ts index 266c2ae..d9cc8f7 100644 --- a/src/modules/version.test.ts +++ b/src/modules/version.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect } from 'vitest' -import { compareSemver } from './version.js' +import { describe, it, expect, vi } from 'vitest' +import { createRunner } from '../core/dokku.js' +import { createContext } from '../core/context.js' +import { compareSemver, ensureDokkuVersion } from './version.js' describe('compareSemver', () => { it('returns 0 for equal versions', () => { @@ -34,3 +36,53 @@ describe('compareSemver', () => { expect(() => compareSemver('0.37', '0.37.9')).toThrow(/parse/i) }) }) + +describe('ensureDokkuVersion', () => { + it('does nothing when pinned is undefined (no query)', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn() + const ctx = createContext(runner) + await ensureDokkuVersion(ctx, undefined) + expect(runner.query).not.toHaveBeenCalled() + }) + + it('is silent when server version equals pinned', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn().mockResolvedValue('dokku version 0.37.9') + const ctx = createContext(runner) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await ensureDokkuVersion(ctx, '0.37.9') + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('is silent when server version is newer than pinned', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn().mockResolvedValue('0.38.0') + const ctx = createContext(runner) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await ensureDokkuVersion(ctx, '0.37.9') + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('warns when server version is older than pinned', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn().mockResolvedValue('dokku version 0.36.4') + const ctx = createContext(runner) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await ensureDokkuVersion(ctx, '0.37.9') + expect(warnSpy).toHaveBeenCalledTimes(1) + const msg = warnSpy.mock.calls[0][0] as string + expect(msg).toContain('0.36.4') + expect(msg).toContain('0.37.9') + warnSpy.mockRestore() + }) + + it('throws when server output is unparseable', async () => { + const runner = createRunner({ dryRun: false }) + runner.query = vi.fn().mockResolvedValue('something weird') + const ctx = createContext(runner) + await expect(ensureDokkuVersion(ctx, '0.37.9')).rejects.toThrow(/parse/i) + }) +}) diff --git a/src/modules/version.ts b/src/modules/version.ts index d32d919..3e05bfe 100644 --- a/src/modules/version.ts +++ b/src/modules/version.ts @@ -1,3 +1,6 @@ +import type { Context } from '../core/context.js' +import { logWarn } from '../core/logger.js' + const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/ function parseSemver(input: string): [number, number, number] { @@ -14,3 +17,24 @@ export function compareSemver(a: string, b: string): -1 | 0 | 1 { if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1 return 0 } + +export async function ensureDokkuVersion( + ctx: Context, + pinned: string | undefined +): Promise { + if (!pinned) return + + const output = await ctx.query('version') + const match = output.match(/(\d+\.\d+\.\d+)/) + if (!match) { + throw new Error(`Cannot parse Dokku server version from output: ${output}`) + } + const server = match[1] + + if (compareSemver(server, pinned) === -1) { + logWarn( + 'dokku', + `server is v${server} but dokku-compose.yml pins >= v${pinned}. Some features may be unavailable.` + ) + } +} From a7abf3b6fed88670153e1fa5c77bccfeb98edecf Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 15:43:38 -0400 Subject: [PATCH 06/11] feat: check dokku version on up --- src/commands/up.test.ts | 5 ++++- src/commands/up.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/commands/up.test.ts b/src/commands/up.test.ts index bacb1a2..a66af22 100644 --- a/src/commands/up.test.ts +++ b/src/commands/up.test.ts @@ -56,7 +56,10 @@ describe('runUp', () => { const runner = createRunner({ dryRun: false }) runner.check = vi.fn().mockResolvedValue(false) runner.run = vi.fn() - runner.query = vi.fn().mockResolvedValue('') + runner.query = vi.fn().mockImplementation(async (...args: string[]) => { + if (args[0] === 'version') return 'dokku version 0.35.12' + return '' + }) const ctx = createContext(runner) const config = loadConfig(path.join(FIXTURES, 'full.yml')) await runUp(ctx, config, ['funqtion']) diff --git a/src/commands/up.ts b/src/commands/up.ts index 4fae11a..feca072 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -17,6 +17,7 @@ import { ensureDockerAuth } from '../modules/docker-auth.js' import { ensureNetworks } from '../modules/network.js' import { ensurePostgres, ensurePostgresBackups } from '../modules/postgres.js' import { ensureRedis } from '../modules/redis.js' +import { ensureDokkuVersion } from '../modules/version.js' import { ensureAppLinks } from '../modules/links.js' import { ensureGlobalDomains } from '../modules/domains.js' import { ensureGlobalConfig } from '../modules/config.js' @@ -32,6 +33,9 @@ export async function runUp( ? appFilter : Object.keys(config.apps) + // Phase 0: Version check (warning-only, opt-in via dokku.version) + await ensureDokkuVersion(ctx, config.dokku?.version) + // Phase 1: Plugins & host-level auth if (config.plugins) await ensurePlugins(ctx, config.plugins) if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth) From c4b261ed3f86c29c1bec4cbffe3203d1f32e2a5d Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 15:47:58 -0400 Subject: [PATCH 07/11] feat: check dokku version on diff Wire ensureDokkuVersion into computeDiff so that diff, like up, warns when the server's Dokku version is below the pinned minimum. The check is warning-only and opt-in via dokku.version in the config. No diff.test.ts changes were needed: the test fixture omits dokku.version, so ensureDokkuVersion returns immediately without querying the server. --- src/commands/diff.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/diff.ts b/src/commands/diff.ts index bf4332f..c461349 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -3,6 +3,7 @@ import type { Config, AppConfig } from '../core/schema.js' import { computeChange } from '../core/change.js' import { ALL_APP_RESOURCES } from '../resources/index.js' import { maskSensitiveData } from '../core/mask.js' +import { ensureDokkuVersion } from '../modules/version.js' import chalk from 'chalk' type DiffStatus = 'in-sync' | 'changed' | 'missing' | 'extra' @@ -26,6 +27,9 @@ interface DiffResult { export async function computeDiff(ctx: Context, config: Config): Promise { const result: DiffResult = { apps: {}, services: {}, inSync: true } + // Version check (warning-only, opt-in via dokku.version) + await ensureDokkuVersion(ctx, config.dokku?.version) + // Bulk prefetch: run all readAll queries in parallel const prefetched = new Map>() await Promise.all( From 0b09397bb661a316621cbd41c7d509e209fb7873 Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 15:51:18 -0400 Subject: [PATCH 08/11] docs: document dokku.version field --- README.md | 1 + docs/reference/dokku.md | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 docs/reference/dokku.md diff --git a/README.md b/README.md index 61d2365..950a137 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ All features are idempotent — running `up` twice produces no changes. | Feature | Description | Reference | |---------|-------------|-----------| | Apps | Create and destroy Dokku apps | [apps](docs/reference/apps.md) | +| Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) | | Environment Variables | Set config vars per app or globally, with full convergence | [config](docs/reference/config.md) | | Build | Dockerfile path, build context, app.json, build args | [builder](docs/reference/builder.md) | | Docker Options | Custom Docker options per phase (build/deploy/run) | [docker_options](docs/reference/docker_options.md) | diff --git a/docs/reference/dokku.md b/docs/reference/dokku.md new file mode 100644 index 0000000..ccf1468 --- /dev/null +++ b/docs/reference/dokku.md @@ -0,0 +1,37 @@ +# Dokku Version + +Dokku docs: https://dokku.com/docs/getting-started/upgrading/ + +Module: `src/modules/version.ts` + +## YAML Keys + +### Pinned Version (`dokku.version`) + +Declare the minimum Dokku server version your config requires. On `up` and +`diff`, dokku-compose queries the server with `dokku version` and warns if +the server is older than the pinned value. The check is opt-in — if the key +is absent, no check runs. + +The value is interpreted as a **minimum**, not an exact match: a server +running a newer version than pinned is silent. Pre-release suffixes +(`-rc1`, `-beta`) on the server output are tolerated. + +The `export` command writes the running server's version into this field +automatically, so round-tripping `export` → edit → `up` produces a useful +floor without manual effort. + +```yaml +dokku: + version: "0.37.9" # warn if server is < 0.37.9 +``` + +| Value | Behavior | +|-------|----------| +| `"X.Y.Z"` | `dokku version` (query)
warn if server version `< X.Y.Z` | +| absent | no action | + +If the server's `dokku version` output cannot be parsed as `X.Y.Z`, +dokku-compose raises an error rather than continuing silently — that +indicates either the runner is talking to something that isn't Dokku, or +Dokku has changed its output format. From 4b16056820eb749675aee83783aec60b34693cbe Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 15:54:43 -0400 Subject: [PATCH 09/11] docs: clarify pre-release suffix tolerance in dokku.md --- docs/reference/dokku.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/dokku.md b/docs/reference/dokku.md index ccf1468..b9ac194 100644 --- a/docs/reference/dokku.md +++ b/docs/reference/dokku.md @@ -15,7 +15,8 @@ is absent, no check runs. The value is interpreted as a **minimum**, not an exact match: a server running a newer version than pinned is silent. Pre-release suffixes -(`-rc1`, `-beta`) on the server output are tolerated. +(`-rc1`, `-beta`) are tolerated on both the server output and the pinned +value itself — only the `X.Y.Z` numeric part is compared. The `export` command writes the running server's version into this field automatically, so round-tripping `export` → edit → `up` produces a useful From 4ad645cbc00c7c39b07bb675670819e4c06f5d97 Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 16:13:49 -0400 Subject: [PATCH 10/11] refactor: extract extractServerVersion helper, drop redundant comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Share `extractServerVersion(output)` between `ensureDokkuVersion` and `runExport`. Both call sites previously inlined the same regex against `dokku version` output. - Drop the parenthetical "warning-only, opt-in via dokku.version" portion of the call-site comments — the function name already says what's happening. Keep `up.ts`'s "Phase 0:" prefix to match the existing navigational pattern in that file. --- src/commands/diff.ts | 1 - src/commands/export.ts | 7 +++---- src/commands/up.ts | 2 +- src/modules/version.ts | 10 +++++++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/commands/diff.ts b/src/commands/diff.ts index c461349..2317718 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -27,7 +27,6 @@ interface DiffResult { export async function computeDiff(ctx: Context, config: Config): Promise { const result: DiffResult = { apps: {}, services: {}, inSync: true } - // Version check (warning-only, opt-in via dokku.version) await ensureDokkuVersion(ctx, config.dokku?.version) // Bulk prefetch: run all readAll queries in parallel diff --git a/src/commands/export.ts b/src/commands/export.ts index b1f8192..17e0dd6 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -6,6 +6,7 @@ import { exportPostgres } from '../modules/postgres.js' import { exportRedis } from '../modules/redis.js' import { exportAppLinks } from '../modules/links.js' import { exportNetworks } from '../modules/network.js' +import { extractServerVersion } from '../modules/version.js' export interface ExportOptions { appFilter?: string[] @@ -14,10 +15,8 @@ export interface ExportOptions { export async function runExport(ctx: Context, opts: ExportOptions): Promise { const config: Config = { apps: {} } - // Dokku version - const versionOutput = await ctx.query('version') - const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+)/) - if (versionMatch) config.dokku = { version: versionMatch[1] } + const version = extractServerVersion(await ctx.query('version')) + if (version) config.dokku = { version } // Apps const apps = opts.appFilter?.length ? opts.appFilter : await exportApps(ctx) diff --git a/src/commands/up.ts b/src/commands/up.ts index feca072..03711c5 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -33,7 +33,7 @@ export async function runUp( ? appFilter : Object.keys(config.apps) - // Phase 0: Version check (warning-only, opt-in via dokku.version) + // Phase 0: Version check await ensureDokkuVersion(ctx, config.dokku?.version) // Phase 1: Plugins & host-level auth diff --git a/src/modules/version.ts b/src/modules/version.ts index 3e05bfe..7ee0a9e 100644 --- a/src/modules/version.ts +++ b/src/modules/version.ts @@ -9,6 +9,11 @@ function parseSemver(input: string): [number, number, number] { return [Number(m[1]), Number(m[2]), Number(m[3])] } +export function extractServerVersion(output: string): string | null { + const match = output.match(/(\d+\.\d+\.\d+)/) + return match ? match[1] : null +} + export function compareSemver(a: string, b: string): -1 | 0 | 1 { const [aMaj, aMin, aPatch] = parseSemver(a) const [bMaj, bMin, bPatch] = parseSemver(b) @@ -25,11 +30,10 @@ export async function ensureDokkuVersion( if (!pinned) return const output = await ctx.query('version') - const match = output.match(/(\d+\.\d+\.\d+)/) - if (!match) { + const server = extractServerVersion(output) + if (!server) { throw new Error(`Cannot parse Dokku server version from output: ${output}`) } - const server = match[1] if (compareSemver(server, pinned) === -1) { logWarn( From 19069b8e2504dab17ac85962a9f51d6c2e7a8cde Mon Sep 17 00:00:00 2001 From: Steve Strates Date: Tue, 28 Apr 2026 16:15:21 -0400 Subject: [PATCH 11/11] chore: remove design spec and implementation plan from PR --- .../plans/2026-04-28-dokku-version-check.md | 508 ------------------ .../2026-04-28-dokku-version-check-design.md | 140 ----- 2 files changed, 648 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-28-dokku-version-check.md delete mode 100644 docs/superpowers/specs/2026-04-28-dokku-version-check-design.md diff --git a/docs/superpowers/plans/2026-04-28-dokku-version-check.md b/docs/superpowers/plans/2026-04-28-dokku-version-check.md deleted file mode 100644 index d20a61f..0000000 --- a/docs/superpowers/plans/2026-04-28-dokku-version-check.md +++ /dev/null @@ -1,508 +0,0 @@ -# Dokku Version Check Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire up a runtime check for the existing `dokku.version` field so that `up` and `diff` warn when the server's Dokku version is older than what the user pinned. - -**Architecture:** A new module `src/modules/version.ts` exposes a pure semver comparator (`compareSemver`) and a runner-aware orchestration function (`ensureDokkuVersion`). The orchestration function is called once at the start of both `runUp` and `computeDiff`. Server output is parsed with the same regex `export.ts` already uses. Behavior is opt-in: silent when `dokku.version` is absent. - -**Tech Stack:** TypeScript (strict), Vitest (mocking via `vi.fn()`), the existing `Context` and chalk-based logger. - -**Spec:** `docs/superpowers/specs/2026-04-28-dokku-version-check-design.md` - -> **Note on spec:** the spec sketches the function signature as `(runner: DokkuRunner, ...)` informally. The actual codebase passes `Context` to module functions (see `src/modules/plugins.ts` etc.). This plan uses `Context` — that is the correct type. - ---- - -## File Structure - -| File | Action | Responsibility | -|------|--------|----------------| -| `src/modules/version.ts` | Create | `compareSemver` (pure) + `ensureDokkuVersion(ctx, pinned)` | -| `src/modules/version.test.ts` | Create | Unit tests for both exports | -| `src/commands/up.ts` | Modify | Call `ensureDokkuVersion` before plugins | -| `src/commands/diff.ts` | Modify | Call `ensureDokkuVersion` before prefetch | -| `docs/reference/dokku.md` | Create | User-facing reference for the `dokku.version` key | -| `README.md` | Modify | Add a row to the Features table linking to `dokku.md` | - ---- - -## Prerequisites - -- Run `bun install` from the repo root if you haven't already. -- This project uses **Vitest** as the test framework, but tests are invoked through Bun's built-in test runner via `bun test `. No separate Vitest setup is needed — `bun test` discovers Vitest automatically. -- The `Context` type (passed to module functions) is defined at `src/core/context.ts:3`. The `Runner` type (used inside tests via `createRunner`) is at `src/core/dokku.ts:11`. Test files create a runner, override its methods with `vi.fn()`, then wrap with `createContext(runner)` — see `src/modules/plugins.test.ts` for the canonical pattern. - ---- - -## Task 1: `compareSemver` pure helper - -**Files:** -- Create: `src/modules/version.ts` -- Create: `src/modules/version.test.ts` - -- [ ] **Step 1: Write the failing test file** - -Create `src/modules/version.test.ts`: - -```typescript -import { describe, it, expect } from 'vitest' -import { compareSemver } from './version.js' - -describe('compareSemver', () => { - it('returns 0 for equal versions', () => { - expect(compareSemver('0.37.9', '0.37.9')).toBe(0) - }) - - it('returns -1 when a is older (patch)', () => { - expect(compareSemver('0.37.9', '0.37.10')).toBe(-1) - }) - - it('returns 1 when a is newer (minor)', () => { - expect(compareSemver('0.38.0', '0.37.99')).toBe(1) - }) - - it('returns 1 when a is newer (major)', () => { - expect(compareSemver('1.0.0', '0.99.99')).toBe(1) - }) - - it('ignores pre-release suffix when otherwise equal', () => { - expect(compareSemver('0.37.9-rc1', '0.37.9')).toBe(0) - }) - - it('ignores pre-release suffix on both sides', () => { - expect(compareSemver('0.37.9-rc1', '0.37.9-rc2')).toBe(0) - }) - - it('throws when input is not parseable', () => { - expect(() => compareSemver('garbage', '0.37.9')).toThrow(/parse/i) - }) - - it('throws when only major.minor (missing patch)', () => { - expect(() => compareSemver('0.37', '0.37.9')).toThrow(/parse/i) - }) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `bun test src/modules/version.test.ts` -Expected: FAIL — `Cannot find module './version.js'` (or similar import error). - -- [ ] **Step 3: Implement `compareSemver`** - -Create `src/modules/version.ts`: - -```typescript -const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/ - -function parseSemver(input: string): [number, number, number] { - const m = input.match(SEMVER_RE) - if (!m) throw new Error(`Cannot parse version: ${input}`) - return [Number(m[1]), Number(m[2]), Number(m[3])] -} - -export function compareSemver(a: string, b: string): -1 | 0 | 1 { - const [aMaj, aMin, aPatch] = parseSemver(a) - const [bMaj, bMin, bPatch] = parseSemver(b) - if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1 - if (aMin !== bMin) return aMin < bMin ? -1 : 1 - if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1 - return 0 -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `bun test src/modules/version.test.ts` -Expected: PASS — 8 tests passing. - -- [ ] **Step 5: Commit** - -```bash -git add src/modules/version.ts src/modules/version.test.ts -git commit -m "feat: add compareSemver helper" -``` - ---- - -## Task 2: `ensureDokkuVersion` orchestration function - -**Files:** -- Modify: `src/modules/version.ts` -- Modify: `src/modules/version.test.ts` - -- [ ] **Step 1: Replace `src/modules/version.test.ts` with the complete final test file** - -This step replaces the file from Task 1 with the full final version (existing tests + new tests). Imports stay grouped at the top of the file as is the project convention. Note that `warnSpy.mock.calls[0][0]` will contain ANSI color codes (chalk wraps the string in yellow); `toContain('0.36.4')` still works because the digits appear verbatim in the formatted output. - -Replace the contents of `src/modules/version.test.ts` with: - -```typescript -import { describe, it, expect, vi } from 'vitest' -import { createRunner } from '../core/dokku.js' -import { createContext } from '../core/context.js' -import { compareSemver, ensureDokkuVersion } from './version.js' - -describe('compareSemver', () => { - it('returns 0 for equal versions', () => { - expect(compareSemver('0.37.9', '0.37.9')).toBe(0) - }) - - it('returns -1 when a is older (patch)', () => { - expect(compareSemver('0.37.9', '0.37.10')).toBe(-1) - }) - - it('returns 1 when a is newer (minor)', () => { - expect(compareSemver('0.38.0', '0.37.99')).toBe(1) - }) - - it('returns 1 when a is newer (major)', () => { - expect(compareSemver('1.0.0', '0.99.99')).toBe(1) - }) - - it('ignores pre-release suffix when otherwise equal', () => { - expect(compareSemver('0.37.9-rc1', '0.37.9')).toBe(0) - }) - - it('ignores pre-release suffix on both sides', () => { - expect(compareSemver('0.37.9-rc1', '0.37.9-rc2')).toBe(0) - }) - - it('throws when input is not parseable', () => { - expect(() => compareSemver('garbage', '0.37.9')).toThrow(/parse/i) - }) - - it('throws when only major.minor (missing patch)', () => { - expect(() => compareSemver('0.37', '0.37.9')).toThrow(/parse/i) - }) -}) - -describe('ensureDokkuVersion', () => { - it('does nothing when pinned is undefined (no query)', async () => { - const runner = createRunner({ dryRun: false }) - runner.query = vi.fn() - const ctx = createContext(runner) - await ensureDokkuVersion(ctx, undefined) - expect(runner.query).not.toHaveBeenCalled() - }) - - it('is silent when server version equals pinned', async () => { - const runner = createRunner({ dryRun: false }) - runner.query = vi.fn().mockResolvedValue('dokku version 0.37.9') - const ctx = createContext(runner) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await ensureDokkuVersion(ctx, '0.37.9') - expect(warnSpy).not.toHaveBeenCalled() - warnSpy.mockRestore() - }) - - it('is silent when server version is newer than pinned', async () => { - const runner = createRunner({ dryRun: false }) - runner.query = vi.fn().mockResolvedValue('0.38.0') - const ctx = createContext(runner) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await ensureDokkuVersion(ctx, '0.37.9') - expect(warnSpy).not.toHaveBeenCalled() - warnSpy.mockRestore() - }) - - it('warns when server version is older than pinned', async () => { - const runner = createRunner({ dryRun: false }) - runner.query = vi.fn().mockResolvedValue('dokku version 0.36.4') - const ctx = createContext(runner) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await ensureDokkuVersion(ctx, '0.37.9') - expect(warnSpy).toHaveBeenCalledTimes(1) - const msg = warnSpy.mock.calls[0][0] as string - expect(msg).toContain('0.36.4') - expect(msg).toContain('0.37.9') - warnSpy.mockRestore() - }) - - it('throws when server output is unparseable', async () => { - const runner = createRunner({ dryRun: false }) - runner.query = vi.fn().mockResolvedValue('something weird') - const ctx = createContext(runner) - await expect(ensureDokkuVersion(ctx, '0.37.9')).rejects.toThrow(/parse/i) - }) -}) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `bun test src/modules/version.test.ts` -Expected: FAIL — `ensureDokkuVersion is not exported from './version.js'`. - -- [ ] **Step 3: Replace `src/modules/version.ts` with the complete final module file** - -Imports go at the top (project convention). Replace the contents of `src/modules/version.ts` with: - -```typescript -import type { Context } from '../core/context.js' -import { logWarn } from '../core/logger.js' - -const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/ - -function parseSemver(input: string): [number, number, number] { - const m = input.match(SEMVER_RE) - if (!m) throw new Error(`Cannot parse version: ${input}`) - return [Number(m[1]), Number(m[2]), Number(m[3])] -} - -export function compareSemver(a: string, b: string): -1 | 0 | 1 { - const [aMaj, aMin, aPatch] = parseSemver(a) - const [bMaj, bMin, bPatch] = parseSemver(b) - if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1 - if (aMin !== bMin) return aMin < bMin ? -1 : 1 - if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1 - return 0 -} - -export async function ensureDokkuVersion( - ctx: Context, - pinned: string | undefined -): Promise { - if (!pinned) return - - const output = await ctx.query('version') - const match = output.match(/(\d+\.\d+\.\d+)/) - if (!match) { - throw new Error(`Cannot parse Dokku server version from output: ${output}`) - } - const server = match[1] - - if (compareSemver(server, pinned) === -1) { - logWarn( - 'dokku', - `server is v${server} but dokku-compose.yml pins >= v${pinned}. Some features may be unavailable.` - ) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `bun test src/modules/version.test.ts` -Expected: PASS — all tests in the file passing (5 new + 8 from Task 1 = 13). - -- [ ] **Step 5: Commit** - -```bash -git add src/modules/version.ts src/modules/version.test.ts -git commit -m "feat: add ensureDokkuVersion check" -``` - ---- - -## Task 3: Wire into `up` - -**Files:** -- Modify: `src/commands/up.ts:35-36` (insert before "Phase 1: Plugins") - -- [ ] **Step 1a: Add the import** - -In `src/commands/up.ts`, add this line to the module-imports block (the `import { ensure* } from '../modules/*.js'` cluster around lines 15-24). Alphabetical order suggests placing it between `ensureRedis` and `ensureAppLinks` is fine — exact placement isn't enforced: - -```typescript -import { ensureDokkuVersion } from '../modules/version.js' -``` - -- [ ] **Step 1b: Insert the version-check call before Phase 1** - -The current `runUp` body has this structure (verified against `src/commands/up.ts:31-37`): - -```typescript - const apps = appFilter.length > 0 - ? appFilter - : Object.keys(config.apps) - - // Phase 1: Plugins & host-level auth - if (config.plugins) await ensurePlugins(ctx, config.plugins) - if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth) -``` - -**Insert two new lines (a comment and the call) immediately before the `// Phase 1:` comment line. Do NOT modify or remove any existing lines.** The result should look like: - -```typescript - const apps = appFilter.length > 0 - ? appFilter - : Object.keys(config.apps) - - // Phase 0: Version check (warning-only, opt-in via dokku.version) - await ensureDokkuVersion(ctx, config.dokku?.version) - - // Phase 1: Plugins & host-level auth - if (config.plugins) await ensurePlugins(ctx, config.plugins) - if (config.docker_auth) await ensureDockerAuth(ctx, config.docker_auth) -``` - -- [ ] **Step 2: Run the full test suite** - -Run: `bun test` -Expected: PASS — all existing tests still passing, no regressions. - -- [ ] **Step 3: Build to confirm TypeScript compiles** - -Run: `bun run build` -Expected: PASS — no TypeScript errors. - -(End-to-end smoke testing requires a `DOKKU_HOST`. The unit tests in Task 2 fully cover the runtime logic, and the build verifies the wiring compiles. If you have a server available, you can additionally run `DOKKU_HOST= ./bin/dokku-compose up -f src/tests/fixtures/simple.yml --dry-run` — the fixture does not pin `dokku.version`, so no warning should appear.) - -- [ ] **Step 4: Commit** - -```bash -git add src/commands/up.ts -git commit -m "feat: check dokku version on up" -``` - ---- - -## Task 4: Wire into `diff` - -**Files:** -- Modify: `src/commands/diff.ts:26-30` (insert before bulk prefetch) - -- [ ] **Step 1: Add the import and call** - -Edit `src/commands/diff.ts`. Add this import next to the existing imports (around line 4): - -```typescript -import { ensureDokkuVersion } from '../modules/version.js' -``` - -Then insert the version check at the top of `computeDiff`, before the bulk prefetch. The existing block: - -```typescript -export async function computeDiff(ctx: Context, config: Config): Promise { - const result: DiffResult = { apps: {}, services: {}, inSync: true } - - // Bulk prefetch: run all readAll queries in parallel -``` - -becomes: - -```typescript -export async function computeDiff(ctx: Context, config: Config): Promise { - const result: DiffResult = { apps: {}, services: {}, inSync: true } - - // Version check (warning-only, opt-in via dokku.version) - await ensureDokkuVersion(ctx, config.dokku?.version) - - // Bulk prefetch: run all readAll queries in parallel -``` - -- [ ] **Step 2: Run the full test suite** - -Run: `bun test` -Expected: PASS — no regressions. - -- [ ] **Step 3: Commit** - -```bash -git add src/commands/diff.ts -git commit -m "feat: check dokku version on diff" -``` - ---- - -## Task 5: Reference docs + README link - -**Files:** -- Create: `docs/reference/dokku.md` -- Modify: `README.md:107-130` (Features table) - -- [ ] **Step 1: Create the reference doc** - -Create `docs/reference/dokku.md` with exactly this content (a four-backtick fence is used here so the inner three-backtick yaml fence is shown verbatim — the file itself uses normal three-backtick fences): - -````markdown -# Dokku Version - -Dokku docs: https://dokku.com/docs/getting-started/upgrading/ - -Module: `src/modules/version.ts` - -## YAML Keys - -### Pinned Version (`dokku.version`) - -Declare the minimum Dokku server version your config requires. On `up` and -`diff`, dokku-compose queries the server with `dokku version` and warns if -the server is older than the pinned value. The check is opt-in — if the key -is absent, no check runs. - -The value is interpreted as a **minimum**, not an exact match: a server -running a newer version than pinned is silent. Pre-release suffixes -(`-rc1`, `-beta`) on the server output are tolerated. - -The `export` command writes the running server's version into this field -automatically, so round-tripping `export` → edit → `up` produces a useful -floor without manual effort. - -```yaml -dokku: - version: "0.37.9" # warn if server is < 0.37.9 -``` - -| Value | Behavior | -|-------|----------| -| `"X.Y.Z"` | `dokku version` (query)
warn if server version `< X.Y.Z` | -| absent | no action | - -If the server's `dokku version` output cannot be parsed as `X.Y.Z`, -dokku-compose raises an error rather than continuing silently — that -indicates either the runner is talking to something that isn't Dokku, or -Dokku has changed its output format. -```` - -- [ ] **Step 2: Add the row to the README Features table** - -In `README.md`, find the Features table (around lines 111-130). Insert one new row immediately after the `| Apps | ... |` row and before the `| Environment Variables | ... |` row. The new row is: - -```markdown -| Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) | -``` - -After the edit, the top of the table should read exactly: - -```markdown -| Feature | Description | Reference | -|---------|-------------|-----------| -| Apps | Create and destroy Dokku apps | [apps](docs/reference/apps.md) | -| Dokku Version | Warn when the server's Dokku version is older than the pinned floor | [dokku](docs/reference/dokku.md) | -| Environment Variables | Set config vars per app or globally, with full convergence | [config](docs/reference/config.md) | -``` - -- [ ] **Step 3: Verify links resolve** - -Run: `ls docs/reference/dokku.md && grep -F "docs/reference/dokku.md" README.md` -Expected: file listed, README contains the link. - -- [ ] **Step 4: Commit** - -```bash -git add docs/reference/dokku.md README.md -git commit -m "docs: document dokku.version field" -``` - ---- - -## Final Verification - -- [ ] **Step 1: Full test suite** - -Run: `bun test` -Expected: PASS — all tests including the 13 new ones in `version.test.ts`. - -- [ ] **Step 2: Build** - -Run: `bun run build` -Expected: PASS — TypeScript strict mode compiles clean. - -- [ ] **Step 3: Smoke test (offline)** - -`validate` takes a positional file argument (see `src/index.ts:73-75`). - -Run: `./bin/dokku-compose validate src/tests/fixtures/simple.yml` -Expected: no errors and no warning about Dokku version. `validate` runs offline and never queries the server, so the version check must not fire here — that confirms the wiring (Tasks 3 and 4) is in `up`/`diff` only. diff --git a/docs/superpowers/specs/2026-04-28-dokku-version-check-design.md b/docs/superpowers/specs/2026-04-28-dokku-version-check-design.md deleted file mode 100644 index a238649..0000000 --- a/docs/superpowers/specs/2026-04-28-dokku-version-check-design.md +++ /dev/null @@ -1,140 +0,0 @@ -# Dokku version check - -## Summary - -Wire up a runtime check for the existing `dokku.version` field in -`dokku-compose.yml`. When the user has pinned a version, compare it to the -server's reported version on `up` and `diff`. If the server is older than the -pinned floor, emit a warning. If the server's version cannot be parsed, raise -an error. Absent field is silent — today's behavior is preserved by default. - -## Motivation - -`dokku.version` is already in the schema (`src/core/schema.ts:99-101`) and is -written by `export` (`src/commands/export.ts:18-20`), but nothing reads it on -apply. Users round-tripping `export` → edit → `up` get a snapshot of the -server's version embedded in their config with no feedback if they later run -against an older or newer server. A minimum-version check turns that snapshot -into a meaningful floor without forcing exact-version churn on every patch -release. - -## Behavior - -### Semantics - -- The pinned `dokku.version` value is interpreted as **minimum required**. - Server version ≥ pinned is silent. Server version < pinned emits a warning. -- Comparison is numeric `MAJOR.MINOR.PATCH`. Pre-release suffixes - (`-rc1`, `-beta`) on the server output are tolerated and ignored for the - comparison. -- Field absent → silent (no warning, no error). The check is opt-in. - -### Where the check runs - -- `up` — runs once at the start of orchestration, before plugins. Warning - is non-blocking; orchestration proceeds. -- `diff` — surfaces version drift in the summary output when pinned and - mismatched. -- `validate` — **does not** run the check. `validate` is offline today and - must stay offline; adding a server query would change its contract. -- `export` — unchanged. Continues to write the server's reported version - into the emitted config. - -### Error vs. warning - -| Condition | Outcome | -|------------------------------------|----------| -| Field absent | Silent | -| Server version ≥ pinned | Silent | -| Server version < pinned | Warning | -| `dokku version` output unparseable | Error | - -Warning text: - -``` -Dokku server is v0.36.4 but dokku-compose.yml pins >= v0.37.9. Some features may be unavailable. -``` - -Rationale for unparseable → error: a `dokku version` command that returns -something we can't recognise as semver indicates the runner is talking to -something that isn't Dokku, or Dokku has changed its output format. Either -way, silently continuing risks misleading downstream behavior. - -## Architecture - -### New module: `src/modules/version.ts` - -Exports two functions, one pure and one runner-aware: - -```ts -// Pure helper — easy to unit-test without mocks. -// Returns -1 if a < b, 0 if equal, 1 if a > b. -// Throws if either input is not parseable as MAJOR.MINOR.PATCH. -export function compareSemver(a: string, b: string): -1 | 0 | 1 - -// Runner-aware orchestration entry point. -// No-op if `pinned` is undefined. -// Queries `dokku version`, parses, compares, logs. -// Throws on unparseable server output. -export async function ensureDokkuVersion( - runner: DokkuRunner, - pinned: string | undefined, -): Promise -``` - -Parsing regex: `/(\d+)\.(\d+)\.(\d+)/`. This is intentionally identical to -the regex already used in `src/commands/export.ts:19` so the two sites stay -in lockstep — if one ever needs to handle a new format, both do. - -### Wire-up - -- `src/commands/up.ts` — call `ensureDokkuVersion(ctx, config.dokku?.version)` - at the top of orchestration, before plugin installation. Warnings emit via - `logWarn('dokku', ...)` from `src/core/logger.ts`. -- `src/commands/diff.ts` — include a "Dokku version" entry in the summary - output when `config.dokku?.version` is set and the server version is - lower. Quiet when matching or higher. - -### No schema change - -`ConfigSchema.dokku.version` already exists as `z.string().optional()`. No -migration, no version bump of the config format. - -## Testing - -### `src/modules/version.test.ts` - -`compareSemver` — table-driven: - -| `a` | `b` | expected | -|-------------|-------------|----------| -| `0.37.9` | `0.37.9` | `0` | -| `0.37.9` | `0.37.10` | `-1` | -| `0.38.0` | `0.37.99` | `1` | -| `1.0.0` | `0.99.99` | `1` | -| `0.37.9-rc1`| `0.37.9` | `0` | (pre-release suffix ignored) -| `garbage` | `0.37.9` | throws | - -`ensureDokkuVersion` — with mocked `runner.query`: - -- Returns silently when `pinned` is `undefined` (no query made). -- Returns silently when server ≥ pinned. -- Logs a warning when server < pinned. Asserts the message includes both - versions. -- Throws when server output does not contain `X.Y.Z`. - -### Integration - -No new fixture YAML required — `dokku.version` already round-trips through -the existing schema. - -## Out of scope - -- `max_version` / range constraints. Add when there is a known-breaking - upper bound to enforce. Until then, YAGNI. -- Semver constraint strings (`^`, `>=`, etc.). The minimum-version - semantic covers the realistic use case without a parser dependency. -- Pinning Dokku plugin versions against the server. Plugins already accept - a `version` field per plugin entry; that's a separate concern. -- A `--strict` flag to escalate the warning to an error. Easy to add later - if needed; not required for the initial behavior.