Skip to content

Commit b3adf5c

Browse files
authored
v0.2.0: Discord-friendly markdown, User/Group merge, expanded redaction (#12)
## Summary Eight focused commits — fixes user-reported bugs and adds the most-requested workflow improvements for the *arr / homelab support use-case. ### Bugs fixed - **Copy to Clipboard could silently fail.** Added an `execCommand('copy')` fallback path so the modern `navigator.clipboard.writeText` failures (focus loss, browsers that expose the API but throw) no longer leave the user staring at a "Copied!" label that didn't actually copy. - **Open PrivateBin / Gist / logs.notifiarr buttons** now call `window.open` synchronously inside the click handler, then await the clipboard write. Previously the await ate the user-activation token in Safari and the popup was blocked. - **Copy as Markdown was not Discord-compatible.** Discord doesn't render pipe-table markdown — `|` shows literally and `_` / `*` chars in volume paths trigger inline formatting. Added a dedicated **Copy MD (Discord)** button that wraps each table in a fenced code block; the existing GitHub markdown button is preserved as **Copy MD (GitHub)**. ### Features - **User / Group comparison table** sits next to the Volume comparison. Rows: `user:` directive, `PUID`, `PGID`, `group_add`, `UMASK`. The single biggest *arr support question is "why can't service X read files written by service Y?" — a UID/GID mismatch is now obvious in one glance instead of buried in env dumps. - **Derived `user` extra** in the service overview merges the `user:` directive with `PUID`/`PGID` env vars, collapsing to a single value when they match and annotating the directive when they conflict. - **Default tab switched from YAML to Table.** Most users want the structured overview first; YAML stays one click away. - **Case-insensitive PUID / PGID / UMASK lookup** so a typo'd `Puid` still surfaces. ### Redaction expansion Closes the gaps identified in the redaction audit: - Connection-string keys (`*_URL`, `*_URI`, `*_DSN`, `DATABASE_*`, `REDIS_*`, `MONGO_*`, `POSTGRES_*`, etc.). - Vendor token keys: AWS access/secret, Tailscale auth keys, GitHub PATs, any `*webhook*`. - `_FILE` suffix stripping for the Docker-secrets convention. - Value-side scan: basic-auth credentials in URLs (`scheme://user:pass@host`), `ghp_…` / `gho_…` etc., AWS access key IDs (`AKIA…`), Tailscale (`tskey-…-…`), Discord/Slack webhook URLs, JWT-shaped tokens. ### CI / Dependabot - New **dependabot-automerge.yml** uses `gh pr merge --auto --squash` for minor + patch updates, gated on CI green. - Dependabot config now splits dev-deps from prod-deps so dev-only churn auto-merges without dragging runtime deps along. - ci.yml gets `permissions: contents: read` for least-privilege. - prerelease.yml `paths-ignore` skips docs/config-only commits so README edits stop spamming pre-release tags. ### Tests 231 tests pass (was 191). New coverage: - Discord vs GitHub markdown formatters (fence count, section omission, no `### ` in Discord output for older clients). - User-group derivation across all combinations (directive only, PUID/PGID only, both matching, both conflicting, partial, empty). - Case-insensitive env lookup. - 22 new redaction tests for connection strings, basic-auth URLs, vendor tokens, webhook URLs, JWT, and `_FILE` suffix. ### Build Single-file output is 82 KB / 26 KB gzipped — well under the 150 KB CI gate. ## Test plan - [x] `npm test` — 231 tests pass - [x] `npx tsc --noEmit` — clean - [x] `npm run build` — 82 KB output - [ ] Smoke test on the deployed Pages URL after merge - [ ] Verify Discord paste renders with monospace alignment - [ ] Verify GitHub paste renders as a real markdown table - [ ] Confirm Dependabot auto-merge fires on the next minor PR <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added Table, Cards, and YAML as independent output tabs. * GitHub and Discord markdown export formats now available. * User/Group comparison table displaying PUID, PGID, and UMASK details. * Enhanced redaction for environment variables, URLs, and vendor credentials. * **Improvements** * Improved clipboard compatibility across browsers. * **Chores** * Version updated to 0.2.0. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 83d7ba8 commit b3adf5c

22 files changed

Lines changed: 972 additions & 126 deletions

.github/dependabot.yml

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,46 @@
1-
---
21
version: 2
32
updates:
43
- package-ecosystem: github-actions
54
directory: /
65
schedule:
76
interval: weekly
87
day: monday
8+
open-pull-requests-limit: 10
99
commit-message:
10-
prefix: "ci"
10+
prefix: ci
1111
groups:
1212
github-actions:
13-
patterns:
14-
- "*"
13+
patterns: ["*"]
1514
labels:
1615
- dependencies
1716
- github-actions
17+
1818
- package-ecosystem: npm
1919
directory: /
2020
schedule:
2121
interval: weekly
2222
day: monday
23+
open-pull-requests-limit: 10
2324
commit-message:
24-
prefix: "chore"
25+
prefix: chore
26+
prefix-development: chore
27+
include: scope
2528
groups:
26-
npm-minor-patch:
27-
update-types:
28-
- minor
29-
- patch
29+
# Dev-only minor/patch — auto-merge candidates (vitest, typescript,
30+
# vite plugins). One PR keeps churn down.
31+
dev-deps-minor:
32+
dependency-type: development
33+
update-types: [minor, patch]
34+
# Runtime minor/patch — small surface (js-yaml only) but still group.
35+
prod-deps-minor:
36+
dependency-type: production
37+
update-types: [minor, patch]
38+
# All majors get their own PR per package — no grouping — so the
39+
# human review queue can evaluate them individually.
3040
labels:
3141
- dependencies
3242
- npm
43+
ignore:
44+
# Stay on a stable Node major; bump deliberately, not via dependabot.
45+
- dependency-name: "@types/node"
46+
update-types: [version-update:semver-major]

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
push:
77
branches: [main]
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
test:
1114
runs-on: ubuntu-latest
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Dependabot auto-merge
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, synchronize, reopened, ready_for_review]
6+
7+
permissions:
8+
contents: write
9+
pull-requests: write
10+
11+
jobs:
12+
automerge:
13+
if: github.event.pull_request.user.login == 'dependabot[bot]'
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 5
16+
steps:
17+
- name: Fetch Dependabot metadata
18+
id: meta
19+
uses: dependabot/fetch-metadata@v2.4.0
20+
with:
21+
github-token: ${{ secrets.GITHUB_TOKEN }}
22+
23+
# Auto-merge minor and patch updates only — major updates go to a
24+
# human review queue. github-actions and dev-only npm minor/patch are
25+
# the safest categories and historically clean every time CI passes.
26+
- name: Enable auto-merge for safe updates
27+
if: |
28+
steps.meta.outputs.update-type == 'version-update:semver-minor' ||
29+
steps.meta.outputs.update-type == 'version-update:semver-patch'
30+
env:
31+
PR_URL: ${{ github.event.pull_request.html_url }}
32+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33+
run: gh pr merge --auto --squash "$PR_URL"

.github/workflows/prerelease.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ name: Pre-release
33
on:
44
push:
55
branches: [main]
6+
# Skip pre-release for changes that don't affect the built artifact.
7+
paths-ignore:
8+
- '**.md'
9+
- '.github/dependabot.yml'
10+
- '.github/ISSUE_TEMPLATE/**'
11+
- '.github/PULL_REQUEST_TEMPLATE/**'
12+
- '.coderabbit.yaml'
13+
- '.gitleaks.toml'
14+
- '.pre-commit-config.yaml'
15+
- '.yamllint.yml'
16+
- '.gitignore'
17+
- 'LICENSE'
618

719
permissions:
820
contents: write

README.md

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,41 @@ Browser-based tool that turns messy Docker Compose output into clean, readable d
66

77
## Features
88

9-
### Service Cards
9+
### Three views
1010

11-
Parsed per-service view showing image, ports, volumes, networks, environment, and extras (restart policy, hostname, depends_on, resource limits). Empty sections are omitted. Switch between YAML and Cards views with the tab bar.
11+
- **Table** *(default)* — service overview + User/Group comparison + Volume comparison, all in one place. Best for quickly spotting UID/GID mismatches or which services share which host paths.
12+
- **Cards** — per-service view showing image, ports, volumes, networks, environment, and extras (user, restart policy, hostname, depends_on, resource limits). Empty sections are omitted.
13+
- **YAML** — full sanitized YAML output, ready to paste into a gist.
1214

13-
### Markdown Table
15+
### Copy as Markdown — GitHub or Discord
1416

15-
One-click "Copy as Markdown Table" generates a table with columns for Service, Image, Ports, Volumes, and Networks — paste directly into Discord or GitHub issues.
17+
Two dedicated buttons:
18+
19+
- **Copy MD (GitHub)**`### heading` + bare pipe-table markdown. Renders as a real table on GitHub.
20+
- **Copy MD (Discord)**`**bold**` labels + each table wrapped in a fenced code block. Discord doesn't render pipe tables, so the fence preserves alignment in monospace and prevents `_underscore_` / `*asterisk*` characters in volume paths from triggering inline formatting.
21+
22+
Both formats include the Services overview, User/Group comparison, and Volume comparison sections.
23+
24+
### User / Group merging
25+
26+
The "User" column merges three sources of identity into a single value so you can spot mismatches at a glance:
27+
28+
- explicit `user: <UID>:<GID>` directive
29+
- `PUID` / `PGID` env vars (linuxserver convention)
30+
- `group_add` and `UMASK` in the comparison table
31+
32+
Lookups are case-insensitive (so a typo'd `Puid` still surfaces). When the directive matches `PUID:PGID`, only one value is shown; when they conflict, the directive is shown with the env values annotated.
1633

1734
### Redaction
1835

1936
| What | Example | Result |
2037
|------|---------|--------|
21-
| Sensitive env values | `RADARR__POSTGRES__HOST: db.example.com` | `RADARR__POSTGRES__HOST: **REDACTED**` |
38+
| Sensitive env keys | `MYSQL_PASSWORD`, `API_KEY`, `DATABASE_URL`, `AWS_SECRET_ACCESS_KEY`, `*_FILE` variants | value replaced with `**REDACTED**` |
39+
| Inline credentials in URLs | `postgres://<user>:<pw>@db/app` | redacted regardless of the env-var name |
40+
| Vendor token formats | GitHub PATs (`ghp_…`), AWS access keys (`AKIA…`), Tailscale auth keys (`tskey-…-…`), Discord/Slack webhooks, JWTs | redacted regardless of the env-var name |
2241
| Email addresses | `NOTIFY: user@example.com` | `NOTIFY: **REDACTED**` |
2342
| Home directory paths | `/home/john/media:/tv` | `~/media:/tv` |
2443

25-
Detected patterns: `password`, `secret`, `token`, `api_key`, `auth`, `credential`, `private_key`, `vpn_user`, and more.
26-
2744
Safe-listed keys (kept as-is): `PUID`, `PGID`, `TZ`, `UMASK`, `LOG_LEVEL`, `WEBUI_PORT`, etc.
2845

2946
### Noise Stripping
@@ -73,19 +90,21 @@ Single-page app built with Vite + vanilla TypeScript. The build produces one sel
7390

7491
```
7592
src/
76-
dom.ts # Shared el() DOM helper (no innerHTML)
77-
patterns.ts # Type guards, regex patterns, utility functions
78-
extract.ts # Extracts YAML from mixed console output
79-
redact.ts # Redacts sensitive values, anonymizes paths
80-
noise.ts # Strips auto-generated noise fields
81-
advisories.ts # Detects misconfigurations (hardlinks, etc.)
82-
services.ts # Parses compose object into ServiceInfo[]
83-
markdown.ts # Generates markdown table from ServiceInfo[]
84-
cards.ts # Renders per-service card DOM
85-
config.ts # Customizable patterns, localStorage persistence
86-
clipboard.ts # Copy, PrivateBin, and Gist sharing
87-
disclaimer.ts # PII warnings and legal disclaimers
88-
main.ts # UI assembly, tabs, and event wiring
93+
dom.ts # Shared el() DOM helper (no innerHTML)
94+
patterns.ts # Key + value regex patterns, type guards, helpers
95+
extract.ts # Extracts YAML from mixed console output
96+
redact.ts # Redacts sensitive values, anonymizes paths
97+
noise.ts # Strips auto-generated noise fields
98+
advisories.ts # Detects misconfigurations (hardlinks, etc.)
99+
services.ts # Parses compose object into ServiceInfo[] + UserGroupInfo
100+
markdown.ts # GitHub + Discord markdown generators
101+
cards.ts # Renders per-service card DOM
102+
volume-table.ts # Service / User-Group / Volume comparison tables
103+
volume-utils.ts # Volume parsing + matrix builder
104+
config.ts # Customizable patterns, localStorage persistence
105+
clipboard.ts # Copy (with execCommand fallback), PrivateBin, Gist
106+
disclaimer.ts # PII warnings and legal disclaimers
107+
main.ts # UI assembly, tabs, and event wiring
89108
```
90109

91110
### Testing

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "docker-compose-debugger",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Browser-based Docker Compose debugger — redacts secrets, shows service cards, and generates markdown tables for support channels",
55
"type": "module",
66
"scripts": {

src/clipboard.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,55 @@
1-
export async function copyToClipboard(text: string): Promise<boolean> {
1+
function legacyCopy(text: string): boolean {
2+
if (typeof document === 'undefined' || document.body === null) return false
3+
4+
const previouslyFocused =
5+
document.activeElement instanceof HTMLElement ? document.activeElement : null
6+
7+
const textarea = document.createElement('textarea')
8+
textarea.value = text
9+
textarea.setAttribute('readonly', '')
10+
textarea.style.position = 'fixed'
11+
textarea.style.top = '0'
12+
textarea.style.left = '0'
13+
textarea.style.width = '1px'
14+
textarea.style.height = '1px'
15+
textarea.style.opacity = '0'
16+
textarea.style.pointerEvents = 'none'
17+
document.body.appendChild(textarea)
18+
19+
let success = false
220
try {
3-
await navigator.clipboard.writeText(text)
4-
return true
21+
textarea.focus()
22+
textarea.select()
23+
textarea.setSelectionRange(0, text.length)
24+
success = document.execCommand('copy')
525
} catch {
6-
return false
26+
success = false
27+
} finally {
28+
textarea.remove()
29+
if (previouslyFocused) previouslyFocused.focus()
30+
}
31+
32+
return success
33+
}
34+
35+
function isSecureClipboardAvailable(): boolean {
36+
if (typeof navigator === 'undefined') return false
37+
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') return false
38+
// navigator.clipboard.writeText only works in secure contexts. Some browsers
39+
// expose the API but throw at call time; we still try and fall through on error.
40+
return true
41+
}
42+
43+
export async function copyToClipboard(text: string): Promise<boolean> {
44+
if (isSecureClipboardAvailable()) {
45+
try {
46+
await navigator.clipboard.writeText(text)
47+
return true
48+
} catch {
49+
// fall through to legacy path
50+
}
751
}
52+
return legacyCopy(text)
853
}
954

1055
export function openPrivateBin(): void {

src/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ export const DEFAULT_CONFIG: SanitizerConfig = {
1818
'credential',
1919
'private[_\\-.]?key',
2020
'vpn[_\\-.]?user',
21+
'[_.\\-](url|uri|dsn|conn(?:ection)?(?:_string)?)$',
22+
'^(database|redis|mongo|amqp|rabbit|celery|postgres|mysql|elastic)[_.\\-]?(url|uri|dsn)?$',
23+
'aws[_\\-.]?(access|secret)[_\\-.]?key',
24+
'tailscale[_\\-.]?(auth)?[_\\-.]?key',
25+
'webhook',
26+
'pat$',
27+
'^gh[_\\-.]?(token|pat)',
28+
'^(discord|slack|telegram|matrix|teams)[_\\-.]',
29+
'\\b(guild|channel|server|workspace|tenant|application|bot|client)[_\\-.]?id$',
2130
],
2231
safeKeys: [
2332
'PUID', 'PGID', 'TZ', 'UMASK', 'UMASK_SET',

src/extract.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,50 @@ export interface ExtractResult {
66
readonly error: string | null
77
}
88

9+
const HTML_ENTITY_PATTERN = /&(amp|lt|gt|quot|#39|apos|nbsp|#x?[0-9a-f]+);/i
10+
const PERCENT_ENCODED_PATTERN = /%[0-9a-fA-F]{2}/
11+
12+
// When users paste from a rendered HTML page (forum thread, wiki, GitHub diff
13+
// preview, autocompose web demo), the input arrives with HTML entities and/or
14+
// percent-encoded sequences instead of literal characters. YAML will reject
15+
// these. Decode them up front so the rest of the pipeline sees plain text.
16+
function decodeHtmlEntities(input: string): string {
17+
if (typeof document === 'undefined') return input
18+
// The textarea innerHTML trick handles named entities (&amp;), decimal
19+
// (&#34;), and hex (&#x22;) without exposing us to script injection — the
20+
// value is read back as text, never inserted into the live DOM.
21+
const ta = document.createElement('textarea')
22+
ta.innerHTML = input
23+
return ta.value
24+
}
25+
26+
function decodePercentEncoding(input: string): string {
27+
// decodeURIComponent throws on malformed sequences (e.g. lone %). Decode
28+
// each match individually so a single bad sequence doesn't drop the whole
29+
// input.
30+
return input.replace(/%[0-9a-fA-F]{2}/g, match => {
31+
try {
32+
return decodeURIComponent(match)
33+
} catch {
34+
return match
35+
}
36+
})
37+
}
38+
39+
export function normalizeEncodedInput(raw: string): string {
40+
let out = raw
41+
if (HTML_ENTITY_PATTERN.test(out)) {
42+
out = decodeHtmlEntities(out)
43+
}
44+
// Only apply percent-decoding when there are at least two encoded sequences
45+
// so a stray "%2" or "%20" inside a literal string doesn't get mangled.
46+
const matches = out.match(/%[0-9a-fA-F]{2}/g)
47+
if (matches && matches.length >= 2 && PERCENT_ENCODED_PATTERN.test(out)) {
48+
out = decodePercentEncoding(out)
49+
}
50+
return out
51+
}
52+
953
const YAML_START_KEYS = /^(version|services|name|networks|volumes|x-)[\s:]/
1054

1155
const SHELL_PREFIX = /^[$#>]\s|^(sudo\s|docker\s|podman\s)/
@@ -36,7 +80,8 @@ function trimTrailingPrompt(lines: readonly string[]): readonly string[] {
3680
}
3781

3882
export function extractYaml(raw: string): ExtractResult {
39-
const trimmed = raw.trim()
83+
const decoded = normalizeEncodedInput(raw)
84+
const trimmed = decoded.trim()
4085
if (trimmed === '') {
4186
return { yaml: null, error: 'No input provided. Paste your Docker Compose YAML or console output.' }
4287
}

0 commit comments

Comments
 (0)