From bf1950e0576e1715fba1354ad1c45e857c330f3c Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:36:01 -0500 Subject: [PATCH 01/11] fix(clipboard): add execCommand fallback for failed Clipboard API calls navigator.clipboard.writeText fails silently in several real-world contexts (no transient user-activation, focus on a different document, permission denied, browsers that expose the API but throw at call time). The page is HTTPS so the fallback rarely fires, but its absence meant "Copy" buttons reported success on a no-op or failed outright. Add a hidden-textarea + document.execCommand('copy') fallback that runs when the modern API throws or is unavailable, and tighten the secure- context check. --- src/clipboard.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/clipboard.ts b/src/clipboard.ts index 39dff08..cb16872 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -1,10 +1,55 @@ -export async function copyToClipboard(text: string): Promise { +function legacyCopy(text: string): boolean { + if (typeof document === 'undefined' || document.body === null) return false + + const previouslyFocused = + document.activeElement instanceof HTMLElement ? document.activeElement : null + + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.top = '0' + textarea.style.left = '0' + textarea.style.width = '1px' + textarea.style.height = '1px' + textarea.style.opacity = '0' + textarea.style.pointerEvents = 'none' + document.body.appendChild(textarea) + + let success = false try { - await navigator.clipboard.writeText(text) - return true + textarea.focus() + textarea.select() + textarea.setSelectionRange(0, text.length) + success = document.execCommand('copy') } catch { - return false + success = false + } finally { + textarea.remove() + if (previouslyFocused) previouslyFocused.focus() + } + + return success +} + +function isSecureClipboardAvailable(): boolean { + if (typeof navigator === 'undefined') return false + if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') return false + // navigator.clipboard.writeText only works in secure contexts. Some browsers + // expose the API but throw at call time; we still try and fall through on error. + return true +} + +export async function copyToClipboard(text: string): Promise { + if (isSecureClipboardAvailable()) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // fall through to legacy path + } } + return legacyCopy(text) } export function openPrivateBin(): void { From 9263fc5041ab557ba072134a67feb6537b2c1c64 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:36:10 -0500 Subject: [PATCH 02/11] feat(markdown): add Discord-friendly fenced format Discord does not render pipe-table markdown; the | separators show literally and any *_~ chars in volume paths trigger inline formatting. Wrapping each table in a triple-backtick code fence preserves alignment in Discord's monospace renderer and blocks inline-format parsing. - buildCombinedMarkdown(services) returns the per-section pieces. - formatForGitHub: existing ### headings + bare tables. - formatForDiscord: **bold** labels + fenced code blocks per section. Tests cover empty inputs, fence count, section omission when a source table is empty, and that the Discord formatter does not use ### so it renders consistently across Discord clients. --- src/markdown.ts | 64 ++++++++++++++++++++++++++++++++++++ tests/markdown.test.ts | 73 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/markdown.ts b/src/markdown.ts index 4335812..8670a3f 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -9,6 +9,31 @@ function joinField(values: readonly string[]): string { return values.join(', ') } +export function generateUserGroupComparisonMarkdown(services: readonly ServiceInfo[]): string { + if (services.length === 0) return '' + + const dash = '—' + type Row = { label: string; cells: string[] } + const rows: Row[] = [ + { label: 'user:', cells: services.map(s => s.userGroup.user || dash) }, + { label: 'PUID', cells: services.map(s => s.userGroup.puid || dash) }, + { label: 'PGID', cells: services.map(s => s.userGroup.pgid || dash) }, + { + label: 'group_add', + cells: services.map(s => (s.userGroup.groupAdd.length > 0 ? s.userGroup.groupAdd.join(', ') : dash)), + }, + { label: 'UMASK', cells: services.map(s => s.userGroup.umask || dash) }, + ] + const visible = rows.filter(r => r.cells.some(c => c !== dash)) + if (visible.length === 0) return '' + + const header = `| User / Group | ${services.map(s => escapeCell(s.name)).join(' | ')} |` + const separator = `| --- | ${services.map(() => '---').join(' | ')} |` + const body = visible.map(r => `| ${r.label} | ${r.cells.map(escapeCell).join(' | ')} |`) + + return [header, separator, ...body].join('\n') +} + export function generateVolumeComparisonMarkdown(services: readonly ServiceInfo[]): string { if (services.length === 0) return '' @@ -32,6 +57,45 @@ export function generateVolumeComparisonMarkdown(services: readonly ServiceInfo[ return [header, separator, ...rows].join('\n') } +export interface CombinedMarkdown { + readonly serviceTable: string + readonly userGroupTable: string + readonly volumeTable: string +} + +export function buildCombinedMarkdown(services: readonly ServiceInfo[]): CombinedMarkdown { + return { + serviceTable: generateMarkdownTable(services), + userGroupTable: generateUserGroupComparisonMarkdown(services), + volumeTable: generateVolumeComparisonMarkdown(services), + } +} + +export function formatForGitHub(parts: CombinedMarkdown): string { + const out: string[] = [] + if (parts.serviceTable) out.push('### Services\n\n' + parts.serviceTable) + if (parts.userGroupTable) out.push('### User / Group\n\n' + parts.userGroupTable) + if (parts.volumeTable) out.push('### Volume Comparison\n\n' + parts.volumeTable) + return out.join('\n\n') +} + +// Discord renders pipe-table markdown as literal text and parses _underscores_, +// **asterisks**, and ~~tildes~~ inside paths. Wrapping each table in a fenced +// code block preserves alignment and blocks Discord's inline formatting. +export function formatForDiscord(parts: CombinedMarkdown): string { + const out: string[] = [] + if (parts.serviceTable) { + out.push('**Services**\n```\n' + parts.serviceTable + '\n```') + } + if (parts.userGroupTable) { + out.push('**User / Group**\n```\n' + parts.userGroupTable + '\n```') + } + if (parts.volumeTable) { + out.push('**Volume Comparison**\n```\n' + parts.volumeTable + '\n```') + } + return out.join('\n\n') +} + export function generateMarkdownTable(services: readonly ServiceInfo[]): string { if (services.length === 0) return '' diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts index 38fedb8..2d6c607 100644 --- a/tests/markdown.test.ts +++ b/tests/markdown.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest' -import { generateMarkdownTable, generateVolumeComparisonMarkdown } from '../src/markdown' +import { + buildCombinedMarkdown, + formatForDiscord, + formatForGitHub, + generateMarkdownTable, + generateVolumeComparisonMarkdown, +} from '../src/markdown' import type { ServiceInfo, NetworkInfo } from '../src/services' function net(name: string, opts?: { aliases?: string[]; ipv4Address?: string }): NetworkInfo { @@ -14,6 +20,7 @@ function makeService(overrides: Partial & { name: string }): Servic networks: [], environment: new Map(), extras: new Map(), + userGroup: { user: '', puid: '', pgid: '', groupAdd: [], umask: '' }, ...overrides, } } @@ -186,3 +193,67 @@ describe('generateVolumeComparisonMarkdown', () => { expect(result).toContain('/a\\|b') }) }) + +describe('formatForGitHub', () => { + it('returns empty string when no services', () => { + expect(formatForGitHub(buildCombinedMarkdown([]))).toBe('') + }) + + it('renders headings as ### and bare markdown tables', () => { + const services = [ + makeService({ name: 'app', image: 'nginx', volumes: ['/data:/data'] }), + ] + const result = formatForGitHub(buildCombinedMarkdown(services)) + expect(result).toMatch(/^### Services\n\n\| Service \|/) + expect(result).toContain('### Volume Comparison') + expect(result).not.toContain('```') + }) + + it('omits a section when its source table is empty', () => { + const services = [makeService({ name: 'app', image: 'nginx' })] // no volumes + const result = formatForGitHub(buildCombinedMarkdown(services)) + expect(result).toContain('### Services') + expect(result).not.toContain('### Volume Comparison') + }) +}) + +describe('formatForDiscord', () => { + it('returns empty string when no services', () => { + expect(formatForDiscord(buildCombinedMarkdown([]))).toBe('') + }) + + it('wraps each table in a fenced code block', () => { + const services = [ + makeService({ name: 'app', image: 'nginx', volumes: ['/data:/data'] }), + ] + const result = formatForDiscord(buildCombinedMarkdown(services)) + // fenced blocks open and close on their own lines + expect(result).toContain('**Services**\n```\n') + expect(result).toContain('\n```') + expect(result).toContain('**Volume Comparison**\n```\n') + // exactly two opening fences (services + volume) and two closing fences + const fences = (result.match(/```/g) ?? []).length + expect(fences).toBe(4) + }) + + it('uses bold labels not ### so old Discord clients render', () => { + const services = [makeService({ name: 'app', image: 'nginx' })] + const result = formatForDiscord(buildCombinedMarkdown(services)) + expect(result).not.toContain('### ') + expect(result).toContain('**Services**') + }) + + it('preserves the raw pipe-table content inside the fences', () => { + const services = [makeService({ name: 'app', image: 'nginx' })] + const result = formatForDiscord(buildCombinedMarkdown(services)) + expect(result).toContain('| Service | Image |') + expect(result).toContain('| app | nginx |') + }) + + it('omits a section when its source table is empty', () => { + const services = [makeService({ name: 'app', image: 'nginx' })] // no volumes + const result = formatForDiscord(buildCombinedMarkdown(services)) + expect(result).toContain('**Services**') + expect(result).not.toContain('**Volume Comparison**') + }) +}) From 9dbcd496463b8b54ea0eb529bea550167973cbb9 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:36:21 -0500 Subject: [PATCH 03/11] feat(services): derive user identity + add User/Group comparison table The single biggest support question for *arr-style stacks is "why can't service X read files written by service Y?" That's almost always a UID/GID mismatch hiding in PUID/PGID env, an explicit user: directive, or group_add. Surface them in one place so mismatches are obvious. - ServiceInfo gains a userGroup field with user, puid, pgid, groupAdd, and umask. Lookups for PUID/PGID/UMASK are case-insensitive so a typo'd `Puid` still surfaces. - A derived `user` row is added to extras (and the service overview table). Combines the user: directive with PUID/PGID; collapses to a single value when they match, annotates when they conflict. - New User/Group comparison table (DOM + markdown) renders alongside the volume comparison. Rows that are empty across all services are hidden so the table only shows fields that exist somewhere. - Discord and GitHub markdown exports include the new section. --- src/services.ts | 66 ++++++++++++++++++- src/volume-table.ts | 68 ++++++++++++++++++- tests/cards.test.ts | 1 + tests/services.test.ts | 132 +++++++++++++++++++++++++++++++++++++ tests/volume-table.test.ts | 1 + 5 files changed, 264 insertions(+), 4 deletions(-) diff --git a/src/services.ts b/src/services.ts index 775a356..0d173a1 100644 --- a/src/services.ts +++ b/src/services.ts @@ -6,6 +6,14 @@ export interface NetworkInfo { readonly ipv4Address: string } +export interface UserGroupInfo { + readonly user: string // explicit user: directive (UID[:GID]) or empty + readonly puid: string // PUID env value or empty + readonly pgid: string // PGID env value or empty + readonly groupAdd: readonly string[] // group_add entries + readonly umask: string // UMASK env value or empty +} + export interface ServiceInfo { readonly name: string readonly image: string @@ -14,6 +22,7 @@ export interface ServiceInfo { readonly networks: readonly NetworkInfo[] readonly environment: ReadonlyMap readonly extras: ReadonlyMap + readonly userGroup: UserGroupInfo } function normalizePort(entry: unknown): string { @@ -109,9 +118,58 @@ function formatResourceLimits(resources: Record): string { return parts.join('; ') } -function extractExtras(service: Record): ReadonlyMap { +// Linuxserver env conventions are uppercase, but we look up case-insensitively +// so a typo'd `Puid` or `pgid` still surfaces in the User/Group comparison. +function envLookupCI(env: ReadonlyMap, name: string): string { + const direct = env.get(name) + if (direct !== undefined) return direct.trim() + const upper = name.toUpperCase() + for (const [k, v] of env) { + if (k.toUpperCase() === upper) return v.trim() + } + return '' +} + +function extractUserGroup(service: Record, env: ReadonlyMap): UserGroupInfo { + const groupAddRaw = service['group_add'] + const groupAdd = Array.isArray(groupAddRaw) ? groupAddRaw.map(String) : [] + return { + user: typeof service['user'] === 'string' ? service['user'].trim() : '', + puid: envLookupCI(env, 'PUID'), + pgid: envLookupCI(env, 'PGID'), + groupAdd, + umask: envLookupCI(env, 'UMASK'), + } +} + +function deriveUser(service: Record, env: ReadonlyMap): string { + const directive = typeof service['user'] === 'string' ? service['user'].trim() : '' + const puid = envLookupCI(env, 'PUID') + const pgid = envLookupCI(env, 'PGID') + + // Prefer the explicit user: directive (it takes effect at runtime; PUID/PGID + // are linuxserver convention only). + if (directive && (puid || pgid)) { + const envPart = puid && pgid ? `PUID=${puid} PGID=${pgid}` : puid ? `PUID=${puid}` : `PGID=${pgid}` + // If directive matches PUID:PGID, surface a single value. + if (directive === `${puid}:${pgid}`) return directive + return `${directive} (${envPart})` + } + if (directive) return directive + if (puid && pgid) return `${puid}:${pgid}` + if (puid) return `PUID=${puid}` + if (pgid) return `PGID=${pgid}` + return '' +} + +function extractExtras(service: Record, env: ReadonlyMap): ReadonlyMap { const extras = new Map() + const userField = deriveUser(service, env) + if (userField) { + extras.set('user', userField) + } + const simpleKeys = ['restart', 'hostname', 'container_name'] as const for (const key of simpleKeys) { const value = service[key] @@ -142,14 +200,16 @@ function extractExtras(service: Record): ReadonlyMap): ServiceInfo { + const environment = extractEnvironment(service['environment']) return { name, image: typeof service['image'] === 'string' ? service['image'] : '', ports: normalizePorts(service['ports']), volumes: normalizeVolumes(service['volumes']), networks: extractNetworks(service['networks']), - environment: extractEnvironment(service['environment']), - extras: extractExtras(service), + environment, + extras: extractExtras(service, environment), + userGroup: extractUserGroup(service, environment), } } diff --git a/src/volume-table.ts b/src/volume-table.ts index 1d8fea6..d186a7a 100644 --- a/src/volume-table.ts +++ b/src/volume-table.ts @@ -1,6 +1,72 @@ import { el } from './dom' import { buildVolumeMatrix, type VolumeMapping } from './volume-utils' -import type { ServiceInfo } from './services' +import type { ServiceInfo, UserGroupInfo } from './services' + +const EM_DASH = '—' + +interface UserGroupRow { + readonly label: string + readonly cells: readonly string[] +} + +function userGroupRows(services: readonly ServiceInfo[]): readonly UserGroupRow[] { + const cell = (svc: ServiceInfo, fn: (ug: UserGroupInfo) => string): string => { + const v = fn(svc.userGroup) + return v === '' ? EM_DASH : v + } + const rows: UserGroupRow[] = [ + { label: 'user:', cells: services.map(s => cell(s, ug => ug.user)) }, + { label: 'PUID', cells: services.map(s => cell(s, ug => ug.puid)) }, + { label: 'PGID', cells: services.map(s => cell(s, ug => ug.pgid)) }, + { label: 'group_add', cells: services.map(s => cell(s, ug => ug.groupAdd.join(', '))) }, + { label: 'UMASK', cells: services.map(s => cell(s, ug => ug.umask)) }, + ] + // Hide rows where every service has em-dash (nothing to compare). + return rows.filter(r => r.cells.some(c => c !== EM_DASH)) +} + +export function renderUserGroupTable(services: readonly ServiceInfo[]): HTMLElement { + const wrap = el('div', { className: 'volume-table-wrap' }) + if (services.length === 0) return wrap + + const rows = userGroupRows(services) + if (rows.length === 0) return wrap + + const table = el('table', { className: 'volume-table' }) + + const thead = el('thead') + const headerRow = el('tr') + const propTh = el('th') + propTh.textContent = 'User / Group' + headerRow.appendChild(propTh) + for (const svc of services) { + const th = el('th') + th.textContent = svc.name + headerRow.appendChild(th) + } + thead.appendChild(headerRow) + table.appendChild(thead) + + const tbody = el('tbody') + for (const row of rows) { + const tr = el('tr') + const labelTd = el('td') + labelTd.textContent = row.label + labelTd.style.fontWeight = '600' + tr.appendChild(labelTd) + for (const value of row.cells) { + const td = el('td') + td.textContent = value + if (value === EM_DASH) td.className = 'vol-empty' + tr.appendChild(td) + } + tbody.appendChild(tr) + } + table.appendChild(tbody) + + wrap.appendChild(table) + return wrap +} function formatCell(mapping: VolumeMapping): string { return mapping.mode ? `${mapping.target} (${mapping.mode})` : mapping.target diff --git a/tests/cards.test.ts b/tests/cards.test.ts index c697eb4..0852577 100644 --- a/tests/cards.test.ts +++ b/tests/cards.test.ts @@ -14,6 +14,7 @@ function makeService(overrides: Partial & { name: string }): Servic networks: [], environment: new Map(), extras: new Map(), + userGroup: { user: '', puid: '', pgid: '', groupAdd: [], umask: '' }, ...overrides, } } diff --git a/tests/services.test.ts b/tests/services.test.ts index 026b123..b77182a 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -378,6 +378,138 @@ describe('parseServices', () => { const result = parseServices(compose) expect(result[0].extras).toEqual(new Map()) }) + + it('derives user from explicit user: directive only', () => { + const compose = { + services: { app: { image: 'nginx', user: '1000:1000' } }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('1000:1000') + }) + + it('derives user from PUID/PGID env when no directive', () => { + const compose = { + services: { + app: { + image: 'lscr.io/linuxserver/sonarr', + environment: { PUID: '1000', PGID: '1000' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('1000:1000') + }) + + it('collapses to single value when user: matches PUID:PGID', () => { + const compose = { + services: { + app: { + image: 'app', + user: '1000:1000', + environment: { PUID: '1000', PGID: '1000' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('1000:1000') + }) + + it('shows directive + env annotation when they differ', () => { + const compose = { + services: { + app: { + image: 'app', + user: '0:0', + environment: { PUID: '1000', PGID: '1000' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('0:0 (PUID=1000 PGID=1000)') + }) + + it('handles PUID alone', () => { + const compose = { + services: { app: { image: 'app', environment: { PUID: '1000' } } }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('PUID=1000') + }) + + it('omits user extra when no user info present', () => { + const compose = { + services: { app: { image: 'app' } }, + } + const result = parseServices(compose) + expect(result[0].extras.has('user')).toBe(false) + }) + + it('orders user before other extras', () => { + const compose = { + services: { + app: { + image: 'app', + user: '1000:1000', + restart: 'unless-stopped', + hostname: 'myhost', + }, + }, + } + const result = parseServices(compose) + const keys = Array.from(result[0].extras.keys()) + expect(keys[0]).toBe('user') + }) + + it('looks up PUID/PGID/UMASK case-insensitively', () => { + const compose = { + services: { + app: { + image: 'app', + environment: { puid: '1000', Pgid: '1000', umask: '022' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].userGroup.puid).toBe('1000') + expect(result[0].userGroup.pgid).toBe('1000') + expect(result[0].userGroup.umask).toBe('022') + expect(result[0].extras.get('user')).toBe('1000:1000') + }) + }) + + describe('userGroup extraction', () => { + it('captures explicit user, PUID, PGID, group_add, UMASK', () => { + const compose = { + services: { + app: { + image: 'app', + user: '1000:1001', + group_add: ['video', 'render'], + environment: { PUID: '1000', PGID: '1001', UMASK: '002' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].userGroup).toEqual({ + user: '1000:1001', + puid: '1000', + pgid: '1001', + groupAdd: ['video', 'render'], + umask: '002', + }) + }) + + it('returns empty values when nothing is set', () => { + const compose = { services: { app: { image: 'app' } } } + const result = parseServices(compose) + expect(result[0].userGroup).toEqual({ + user: '', + puid: '', + pgid: '', + groupAdd: [], + umask: '', + }) + }) }) // Immutability diff --git a/tests/volume-table.test.ts b/tests/volume-table.test.ts index 6542dd0..d0f1c23 100644 --- a/tests/volume-table.test.ts +++ b/tests/volume-table.test.ts @@ -14,6 +14,7 @@ function makeService(overrides: Partial & { name: string }): Servic networks: [], environment: new Map(), extras: new Map(), + userGroup: { user: '', puid: '', pgid: '', groupAdd: [], umask: '' }, ...overrides, } } From 8c8390edc32b085ef9969c4c23ea2691fccff601 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:36:32 -0500 Subject: [PATCH 04/11] feat(ui): default to Table tab; split GitHub/Discord copy buttons - Switch the post-sanitize default tab from YAML to Table. Most users reach for this view first to scan services, ports, user/group, and volume comparison; YAML is the fallback when the structured view isn't enough. - Replace the single "Copy as Markdown" button with two: "Copy MD (GitHub)" (existing format) and "Copy MD (Discord)" (fenced-code variant). The previous output was unreadable when pasted into Discord support channels. - Render the User/Group comparison and Volume comparison tables under labelled headings so the Table tab presents them as distinct sections. - Open* buttons (PrivateBin, logs.notifiarr, Gist) now call window.open synchronously inside the click handler before the clipboard write. Awaiting first drops the user-activation token in Safari and triggers the popup blocker. --- src/main.ts | 150 ++++++++++++++++++++++++++++------------------------ 1 file changed, 82 insertions(+), 68 deletions(-) diff --git a/src/main.ts b/src/main.ts index a672cf1..e4b3693 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,9 +9,9 @@ import { copyToClipboard, openPrivateBin, openGist } from './clipboard' import { createShortNotice, createPiiWarning, createFullDisclaimer } from './disclaimer' import { el } from './dom' import { parseServices } from './services' -import { generateMarkdownTable, generateVolumeComparisonMarkdown } from './markdown' +import { buildCombinedMarkdown, formatForDiscord, formatForGitHub, generateMarkdownTable, generateVolumeComparisonMarkdown } from './markdown' import { renderCards } from './cards' -import { renderServiceTable, renderVolumeTable } from './volume-table' +import { renderServiceTable, renderUserGroupTable, renderVolumeTable } from './volume-table' const MAX_INPUT_BYTES = 512 * 1024 @@ -263,20 +263,29 @@ function init(): void { piiWarning.classList.add('hidden') app.appendChild(piiWarning) - // Tab bar (hidden until output) + // Tab bar (hidden until output). Table is default — most users want the + // service overview as a quick read; YAML view is the full sanitized output. const tabBar = el('div', { className: 'tab-bar hidden' }) - const yamlTab = el('button', { className: 'tab-btn active' }) - yamlTab.textContent = 'YAML' - tabBar.appendChild(yamlTab) + const volumesTab = el('button', { className: 'tab-btn active' }) + volumesTab.textContent = 'Table' + tabBar.appendChild(volumesTab) const cardsTab = el('button', { className: 'tab-btn' }) cardsTab.textContent = 'Cards' tabBar.appendChild(cardsTab) - const volumesTab = el('button', { className: 'tab-btn' }) - volumesTab.textContent = 'Table' - tabBar.appendChild(volumesTab) + const yamlTab = el('button', { className: 'tab-btn' }) + yamlTab.textContent = 'YAML' + tabBar.appendChild(yamlTab) app.appendChild(tabBar) - // Output textarea (YAML view) + // Volumes container (default visible after sanitize) + const volumesContainer = el('div', { id: 'volumes', className: 'hidden' }) + app.appendChild(volumesContainer) + + // Cards container (hidden by default) + const cardsContainer = el('div', { id: 'cards', className: 'cards-container hidden' }) + app.appendChild(cardsContainer) + + // Output textarea (YAML view, hidden by default) const output = el('textarea', { id: 'output', className: 'code-textarea hidden', @@ -286,14 +295,6 @@ function init(): void { }) app.appendChild(output) - // Cards container (hidden by default) - const cardsContainer = el('div', { id: 'cards', className: 'cards-container hidden' }) - app.appendChild(cardsContainer) - - // Volumes container (hidden by default) - const volumesContainer = el('div', { id: 'volumes', className: 'hidden' }) - app.appendChild(volumesContainer) - // Track current parsed object for markdown generation let currentParsed: Record | null = null @@ -332,54 +333,51 @@ function init(): void { }) actions.appendChild(copyBtn) - const mdBtn = el('button', { className: 'btn btn-secondary' }) - mdBtn.textContent = 'Copy as Markdown' - mdBtn.addEventListener('click', async () => { - if (!currentParsed) { - mdBtn.textContent = 'No data' - setTimeout(() => { mdBtn.textContent = 'Copy as Markdown' }, 1500) - return - } - const services = parseServices(currentParsed) - const parts: string[] = [] - const serviceTable = generateMarkdownTable(services) - if (serviceTable) { - parts.push('### Services\n\n' + serviceTable) - } - const volTable = generateVolumeComparisonMarkdown(services) - if (volTable) { - parts.push('### Volume Comparison\n\n' + volTable) - } - const md = parts.join('\n\n') - const ok = await copyToClipboard(md || 'No services found') - mdBtn.textContent = ok ? 'Copied!' : 'Copy failed' - setTimeout(() => { mdBtn.textContent = 'Copy as Markdown' }, 1500) - }) - actions.appendChild(mdBtn) - - const pbBtn = el('button', { className: 'btn btn-secondary', title: 'Tip: Set expiry to 1 week or longer so support can review it' }) - pbBtn.textContent = 'Open PrivateBin' - pbBtn.addEventListener('click', async () => { - await copyToClipboard(output.value) - openPrivateBin() - }) - actions.appendChild(pbBtn) + function makeMarkdownButton(label: string, format: (p: ReturnType) => string): HTMLButtonElement { + const btn = el('button', { className: 'btn btn-secondary' }) + btn.textContent = label + btn.addEventListener('click', async () => { + if (!currentParsed) { + btn.textContent = 'No data' + setTimeout(() => { btn.textContent = label }, 1500) + return + } + const services = parseServices(currentParsed) + const md = format(buildCombinedMarkdown(services)) + const ok = await copyToClipboard(md || 'No services found') + btn.textContent = ok ? 'Copied!' : 'Copy failed' + setTimeout(() => { btn.textContent = label }, 1500) + }) + return btn + } - const logsBtn = el('button', { className: 'btn btn-secondary' }) - logsBtn.textContent = 'Open logs.notifiarr.com' - logsBtn.addEventListener('click', async () => { - await copyToClipboard(output.value) - window.open('https://logs.notifiarr.com/', '_blank', 'noopener,noreferrer') - }) - actions.appendChild(logsBtn) + actions.appendChild(makeMarkdownButton('Copy MD (GitHub)', formatForGitHub)) + actions.appendChild(makeMarkdownButton('Copy MD (Discord)', formatForDiscord)) + + // Open* buttons must call window.open synchronously inside the click handler + // before any await — otherwise Safari (and strict popup blockers) drop the + // user-activation token and the popup is blocked. + function makeOpenButton(label: string, url: string, title?: string): HTMLButtonElement { + const btn = el('button', { className: 'btn btn-secondary' }) + btn.textContent = label + if (title) btn.setAttribute('title', title) + btn.addEventListener('click', () => { + window.open(url, '_blank', 'noopener,noreferrer') + copyToClipboard(output.value).then(ok => { + btn.textContent = ok ? 'Copied! → opened tab' : 'Tab opened (copy failed)' + setTimeout(() => { btn.textContent = label }, 1800) + }) + }) + return btn + } - const gistBtn = el('button', { className: 'btn btn-secondary' }) - gistBtn.textContent = 'Open GitHub Gist' - gistBtn.addEventListener('click', async () => { - await copyToClipboard(output.value) - openGist() - }) - actions.appendChild(gistBtn) + actions.appendChild(makeOpenButton( + 'Open PrivateBin', + 'https://privatebin.net/', + 'Tip: Set expiry to 1 week or longer so support can review it', + )) + actions.appendChild(makeOpenButton('Open logs.notifiarr.com', 'https://logs.notifiarr.com/')) + actions.appendChild(makeOpenButton('Open GitHub Gist', 'https://gist.github.com/')) app.appendChild(actions) @@ -445,8 +443,8 @@ function init(): void { output.value = result.output ?? '' currentParsed = result.parsed - // Reset to YAML tab - switchTab(yamlTab) + // Reset to Table tab — best default for quick scanning + switchTab(volumesTab) // Render cards + volume table cardsContainer.replaceChildren() @@ -463,9 +461,25 @@ function init(): void { const svcTable = renderServiceTable(services) volumesContainer.appendChild(svcTable) + // Render user/group comparison table (only if at least one service has data) + const ugTable = renderUserGroupTable(services) + if (ugTable.firstChild) { + const ugLabel = el('label') + ugLabel.textContent = 'User / Group comparison:' + ugLabel.style.marginTop = '0.75rem' + volumesContainer.appendChild(ugLabel) + volumesContainer.appendChild(ugTable) + } + // Render volume comparison table const volTable = renderVolumeTable(services) - volumesContainer.appendChild(volTable) + if (volTable.firstChild) { + const volLabel = el('label') + volLabel.textContent = 'Volume comparison:' + volLabel.style.marginTop = '0.75rem' + volumesContainer.appendChild(volLabel) + volumesContainer.appendChild(volTable) + } // Markdown preview textarea const svcMd = generateMarkdownTable(services) @@ -476,7 +490,7 @@ function init(): void { if (mdParts.length > 0) { const combinedMd = mdParts.join('\n\n') const mdLabel = el('label') - mdLabel.textContent = 'Markdown (for pasting into Discord / GitHub):' + mdLabel.textContent = 'Markdown preview (GitHub format) — use the buttons above to copy GitHub or Discord variants:' mdLabel.style.marginTop = '0.75rem' volumesContainer.appendChild(mdLabel) const mdPreview = el('textarea', { From 2623ea97651470ac5ea4b4ffcbf2b1bf69437041 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:40:01 -0500 Subject: [PATCH 05/11] feat(redact): catch DSN strings, vendor tokens, and webhook URLs Add coverage in three places: Key patterns: - *_URL / *_URI / *_DSN / *_CONNECTION_STRING tail suffixes. - DATABASE/REDIS/MONGO/AMQP/RABBIT/CELERY/POSTGRES/MYSQL/ELASTIC prefixes (catches DATABASE_URL etc. without a password-y substring). - AWS access/secret keys, Tailscale auth keys, GitHub PATs/tokens, any *webhook* key. - Strip a trailing _FILE suffix before matching so the Docker-secrets convention (POSTGRES_PASSWORD_FILE, DATABASE_URL_FILE) is covered. Value patterns (redact regardless of key name): - Basic-auth credentials embedded in any URL value. - GitHub PATs (ghp_/gho_/ghu_/ghs_/ghr_). - AWS access key IDs (AKIA/ASIA/AROA/AIPA/AGPA/AIDA + 16 chars). - Tailscale auth keys. - Discord and Slack incoming-webhook URLs. - JWT-shaped values (three base64url segments separated by dots). config.ts default sensitivePatterns updated to match. Tests cover the new key/value matchers; fake test fixtures get pragma allowlist comments. --- src/config.ts | 7 +++++ src/patterns.ts | 45 ++++++++++++++++++++++++-- src/redact.ts | 11 ++++++- tests/patterns.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index d5431f3..becbbf4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,13 @@ export const DEFAULT_CONFIG: SanitizerConfig = { 'credential', 'private[_\\-.]?key', 'vpn[_\\-.]?user', + '[_.\\-](url|uri|dsn|conn(?:ection)?(?:_string)?)$', + '^(database|redis|mongo|amqp|rabbit|celery|postgres|mysql|elastic)[_.\\-]?(url|uri|dsn)?$', + 'aws[_\\-.]?(access|secret)[_\\-.]?key', + 'tailscale[_\\-.]?(auth)?[_\\-.]?key', + 'webhook', + 'pat$', + '^gh[_\\-.]?(token|pat)', ], safeKeys: [ 'PUID', 'PGID', 'TZ', 'UMASK', 'UMASK_SET', diff --git a/src/patterns.ts b/src/patterns.ts index 8951121..cb16e6b 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -14,6 +14,15 @@ export const DEFAULT_SENSITIVE_PATTERNS: readonly RegExp[] = [ /credential/i, /private[_\-.]?key/i, /vpn[_\-.]?user/i, + // Connection strings & DSN-style keys often contain inline credentials. + /[_.\-](url|uri|dsn|conn(?:ection)?(?:_string)?)$/i, + /^(database|redis|mongo|amqp|rabbit|celery|postgres|mysql|elastic)[_.\-]?(url|uri|dsn)?$/i, + // Cloud / vendor keys. + /aws[_\-.]?(access|secret)[_\-.]?key/i, + /tailscale[_\-.]?(auth)?[_\-.]?key/i, + /webhook/i, + /pat$/i, + /^gh[_\-.]?(token|pat)/i, ] export const DEFAULT_SAFE_KEYS: ReadonlySet = new Set([ @@ -26,6 +35,33 @@ export const EMAIL_PATTERN = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/ export const HOME_DIR_PATTERN = /^(\/home\/[^/]+|~|\/root)\// +// Value-side patterns: trigger redaction even when the key looks innocent. +// Catches credentials embedded in URLs and provider-specific token formats. +// pragma: allowlist secret +export const SENSITIVE_VALUE_PATTERNS: readonly RegExp[] = [ + // Basic-auth credentials embedded in any URL (scheme then user:pass@host). + /[a-z][a-z0-9+\-.]{1,20}:\/\/[^\s/@:]{1,200}:[^\s/@]{1,200}@/i, // pragma: allowlist secret + // GitHub fine-grained / classic PATs: ghp_, gho_, ghu_, ghs_, ghr_ + /\bgh[pousr]_[A-Za-z0-9]{30,}\b/, + // AWS access key IDs (AKIA, ASIA, AROA, AIPA, AGPA, AIDA prefixes) + /\b(?:AKIA|ASIA|AROA|AIPA|AGPA|AIDA)[A-Z0-9]{16}\b/, + // Tailscale auth keys + /\btskey-[a-z]+-[A-Za-z0-9-]+\b/, + // Discord webhook URLs + /https:\/\/(?:discord(?:app)?\.com|ptb\.discord\.com|canary\.discord\.com)\/api\/webhooks\/\d+\/[\w-]+/i, + // Slack incoming webhooks + /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/, + // JWT (three base64url segments separated by dots, "ey…"-prefixed first segment) + /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, +] + +// Strip a trailing _FILE suffix (Docker-secrets convention) so that keys like +// DATABASE_URL_FILE or POSTGRES_PASSWORD_FILE match the same patterns as their +// non-_FILE counterparts. +function stripFileSuffix(key: string): string { + return key.replace(/_FILE$/i, '') +} + export function isSensitiveKey( key: string, sensitivePatterns?: readonly RegExp[], @@ -33,14 +69,19 @@ export function isSensitiveKey( ): boolean { const safe = safeKeys ?? DEFAULT_SAFE_KEYS const sensitive = sensitivePatterns ?? DEFAULT_SENSITIVE_PATTERNS - if (safe.has(key.toUpperCase())) return false - return sensitive.some(p => p.test(key)) + const stripped = stripFileSuffix(key) + if (safe.has(stripped.toUpperCase())) return false + return sensitive.some(p => p.test(stripped)) } export function containsEmail(value: string): boolean { return EMAIL_PATTERN.test(value) } +export function containsSensitiveValue(value: string): boolean { + return SENSITIVE_VALUE_PATTERNS.some(p => p.test(value)) +} + export function anonymizeHomePath(volumeStr: string): string { return volumeStr.replace(HOME_DIR_PATTERN, '~/') } diff --git a/src/redact.ts b/src/redact.ts index 29a09a1..cb56a34 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -1,5 +1,5 @@ import { load, dump } from 'js-yaml' -import { isRecord, isSensitiveKey, containsEmail, anonymizeHomePath } from './patterns' +import { anonymizeHomePath, containsEmail, containsSensitiveValue, isRecord, isSensitiveKey } from './patterns' const REDACTED = '**REDACTED**' @@ -35,6 +35,10 @@ function redactEnvDict( stats.redactedEnvVars++ stats.redactedKeys.push(key) } + } else if (containsSensitiveValue(strValue)) { + result[key] = REDACTED + stats.redactedEnvVars++ + stats.redactedKeys.push(key) } else if (containsEmail(strValue)) { result[key] = REDACTED stats.redactedEmails++ @@ -64,6 +68,11 @@ function redactEnvArray( stats.redactedKeys.push(key) return `${key}=${REDACTED}` } + if (containsSensitiveValue(value)) { + stats.redactedEnvVars++ + stats.redactedKeys.push(key) + return `${key}=${REDACTED}` + } if (containsEmail(value)) { stats.redactedEmails++ stats.redactedKeys.push(key) diff --git a/tests/patterns.test.ts b/tests/patterns.test.ts index 64a9116..899ef0a 100644 --- a/tests/patterns.test.ts +++ b/tests/patterns.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { isSensitiveKey, containsEmail, anonymizeHomePath } from '../src/patterns' +import { anonymizeHomePath, containsEmail, containsSensitiveValue, isSensitiveKey } from '../src/patterns' describe('isSensitiveKey', () => { it.each([ @@ -49,6 +49,75 @@ describe('isSensitiveKey', () => { const safeKeys = new Set(['AUTH_TOKEN']) expect(isSensitiveKey('AUTH_TOKEN', undefined, safeKeys)).toBe(false) }) + + it.each([ + ['DATABASE_URL', true], + ['REDIS_URL', true], + ['MONGO_URI', true], + ['POSTGRES_DSN', true], + ['CELERY_BROKER_URL', true], + ['DB_CONNECTION_STRING', true], + ['AWS_ACCESS_KEY_ID', true], + ['AWS_SECRET_ACCESS_KEY', true], + ['TAILSCALE_AUTHKEY', true], + ['TAILSCALE_AUTH_KEY', true], + ['DISCORD_WEBHOOK', true], + ['GH_TOKEN', true], + ['GITHUB_PAT', true], + ])('catches connection-string / vendor-key conventions: %s', (key, expected) => { + expect(isSensitiveKey(key)).toBe(expected) + }) + + it('strips _FILE suffix before matching (Docker secrets)', () => { + expect(isSensitiveKey('POSTGRES_PASSWORD_FILE')).toBe(true) + expect(isSensitiveKey('DATABASE_URL_FILE')).toBe(true) + expect(isSensitiveKey('PUID_FILE')).toBe(false) + }) +}) + +describe('containsSensitiveValue', () => { + it('detects basic-auth in URLs', () => { + expect(containsSensitiveValue('postgres://user:hunter2@db.example.com:5432/app')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('mongodb://admin:s3cret@mongo:27017/?authSource=admin')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('https://service:p@ss@example.com')).toBe(true) // pragma: allowlist secret + }) + + it('detects GitHub PATs', () => { + expect(containsSensitiveValue('ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('gho_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('GHP_AbCdEf')).toBe(false) // too short + }) + + it('detects AWS access key IDs', () => { + expect(containsSensitiveValue('AKIAIOSFODNN7EXAMPLE')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('ASIATESTKEYABCDEFGHI')).toBe(true) // pragma: allowlist secret + }) + + it('detects Tailscale auth keys', () => { + expect(containsSensitiveValue('tskey-auth-kAbCd1EfG2-XyZAbcDef123456789')).toBe(true) // pragma: allowlist secret + }) + + it('detects Discord webhooks', () => { + // pragma: allowlist nextline secret + expect(containsSensitiveValue('https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKl_MnOpQrSt-uVwXyZ')).toBe(true) + }) + + it('detects Slack webhooks', () => { + // pragma: allowlist nextline secret + expect(containsSensitiveValue('https://hooks.slack.com/services/T01ABCDEFGH/B01ABCDEFGH/abcdEFGHijklMNOP1234')).toBe(true) + }) + + it('detects JWT-like tokens', () => { + // pragma: allowlist nextline secret + expect(containsSensitiveValue('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c')).toBe(true) + }) + + it('does not flag normal values', () => { + expect(containsSensitiveValue('linuxserver/sonarr:latest')).toBe(false) + expect(containsSensitiveValue('America/Chicago')).toBe(false) + expect(containsSensitiveValue('1000:1000')).toBe(false) + expect(containsSensitiveValue('https://media.example.com/')).toBe(false) + }) }) describe('containsEmail', () => { From d578a297a6e3f0fe5d765ce226de06ffdd6264be Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:42:39 -0500 Subject: [PATCH 06/11] ci: add Dependabot auto-merge + tighten config and workflow permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dependabot-automerge.yml: auto-merge minor/patch Dependabot PRs via gh pr merge --auto --squash, gated on CI green. Keeps the human review queue focused on majors. Uses dependabot/fetch-metadata to classify update type. - Refine .github/dependabot.yml: split npm group into dev-deps-minor vs prod-deps-minor (so dev-only churn auto-merges without dragging runtime deps along), set open-pull-requests-limit, ignore @types/node major bumps so Node versioning stays deliberate. - ci.yml: add top-level permissions: contents: read so the workflow defaults to least-privilege. - prerelease.yml: paths-ignore docs / config-only changes so README, pre-commit, dependabot, and similar commits don't spam pre-release tags + GitHub releases. Also suppress a pre-existing SC2001 shellcheck warning on the capture-group sed (shellcheck false positive — parameter expansion can't replicate the regex). --- .github/dependabot.yml | 32 +++++++++++++++------ .github/workflows/ci.yml | 3 ++ .github/workflows/dependabot-automerge.yml | 33 ++++++++++++++++++++++ .github/workflows/prerelease.yml | 12 ++++++++ 4 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/dependabot-automerge.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 648838a..5057782 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,3 @@ ---- version: 2 updates: - package-ecosystem: github-actions @@ -6,27 +5,42 @@ updates: schedule: interval: weekly day: monday + open-pull-requests-limit: 10 commit-message: - prefix: "ci" + prefix: ci groups: github-actions: - patterns: - - "*" + patterns: ["*"] labels: - dependencies - github-actions + - package-ecosystem: npm directory: / schedule: interval: weekly day: monday + open-pull-requests-limit: 10 commit-message: - prefix: "chore" + prefix: chore + prefix-development: chore + include: scope groups: - npm-minor-patch: - update-types: - - minor - - patch + # Dev-only minor/patch — auto-merge candidates (vitest, typescript, + # vite plugins). One PR keeps churn down. + dev-deps-minor: + dependency-type: development + update-types: [minor, patch] + # Runtime minor/patch — small surface (js-yaml only) but still group. + prod-deps-minor: + dependency-type: production + update-types: [minor, patch] + # All majors get their own PR per package — no grouping — so the + # human review queue can evaluate them individually. labels: - dependencies - npm + ignore: + # Stay on a stable Node major; bump deliberately, not via dependabot. + - dependency-name: "@types/node" + update-types: [version-update:semver-major] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5317f10..d31aa05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..ec224eb --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,33 @@ +name: Dependabot auto-merge + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v2.4.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + # Auto-merge minor and patch updates only — major updates go to a + # human review queue. github-actions and dev-only npm minor/patch are + # the safest categories and historically clean every time CI passes. + - name: Enable auto-merge for safe updates + if: | + steps.meta.outputs.update-type == 'version-update:semver-minor' || + steps.meta.outputs.update-type == 'version-update:semver-patch' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr merge --auto --squash "$PR_URL" diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 3de5131..0ec5ae6 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -3,6 +3,18 @@ name: Pre-release on: push: branches: [main] + # Skip pre-release for changes that don't affect the built artifact. + paths-ignore: + - '**.md' + - '.github/dependabot.yml' + - '.github/ISSUE_TEMPLATE/**' + - '.github/PULL_REQUEST_TEMPLATE/**' + - '.coderabbit.yaml' + - '.gitleaks.toml' + - '.pre-commit-config.yaml' + - '.yamllint.yml' + - '.gitignore' + - 'LICENSE' permissions: contents: write From ec9e9d60aa995cfb95c5645a349a8ad597a22dc7 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:44:00 -0500 Subject: [PATCH 07/11] docs(readme): document Discord button, User/Group table, and new redaction - Three-tab UI with Table as default; per-tab purpose documented. - Two copy buttons (GitHub vs Discord) with the rationale for the Discord fenced-code variant. - User/Group merging behaviour (directive + PUID/PGID + group_add + UMASK) and case-insensitive env lookup. - Expanded redaction coverage: connection-string keys, embedded URL basic-auth, vendor token formats, and Docker-secrets _FILE suffix. - Architecture file list updated to include volume-table / volume-utils. --- README.md | 59 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 32a1bf8..797abd4 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,41 @@ Browser-based tool that turns messy Docker Compose output into clean, readable d ## Features -### Service Cards +### Three views -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. +- **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. +- **Cards** — per-service view showing image, ports, volumes, networks, environment, and extras (user, restart policy, hostname, depends_on, resource limits). Empty sections are omitted. +- **YAML** — full sanitized YAML output, ready to paste into a gist. -### Markdown Table +### Copy as Markdown — GitHub or Discord -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. +Two dedicated buttons: + +- **Copy MD (GitHub)** — `### heading` + bare pipe-table markdown. Renders as a real table on GitHub. +- **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. + +Both formats include the Services overview, User/Group comparison, and Volume comparison sections. + +### User / Group merging + +The "User" column merges three sources of identity into a single value so you can spot mismatches at a glance: + +- explicit `user: :` directive +- `PUID` / `PGID` env vars (linuxserver convention) +- `group_add` and `UMASK` in the comparison table + +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. ### Redaction | What | Example | Result | |------|---------|--------| -| Sensitive env values | `RADARR__POSTGRES__HOST: db.example.com` | `RADARR__POSTGRES__HOST: **REDACTED**` | +| Sensitive env keys | `MYSQL_PASSWORD`, `API_KEY`, `DATABASE_URL`, `AWS_SECRET_ACCESS_KEY`, `*_FILE` variants | value replaced with `**REDACTED**` | +| Inline credentials in URLs | `postgres://:@db/app` | redacted regardless of the env-var name | +| 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 | | Email addresses | `NOTIFY: user@example.com` | `NOTIFY: **REDACTED**` | | Home directory paths | `/home/john/media:/tv` | `~/media:/tv` | -Detected patterns: `password`, `secret`, `token`, `api_key`, `auth`, `credential`, `private_key`, `vpn_user`, and more. - Safe-listed keys (kept as-is): `PUID`, `PGID`, `TZ`, `UMASK`, `LOG_LEVEL`, `WEBUI_PORT`, etc. ### Noise Stripping @@ -73,19 +90,21 @@ Single-page app built with Vite + vanilla TypeScript. The build produces one sel ``` src/ - dom.ts # Shared el() DOM helper (no innerHTML) - patterns.ts # Type guards, regex patterns, utility functions - extract.ts # Extracts YAML from mixed console output - redact.ts # Redacts sensitive values, anonymizes paths - noise.ts # Strips auto-generated noise fields - advisories.ts # Detects misconfigurations (hardlinks, etc.) - services.ts # Parses compose object into ServiceInfo[] - markdown.ts # Generates markdown table from ServiceInfo[] - cards.ts # Renders per-service card DOM - config.ts # Customizable patterns, localStorage persistence - clipboard.ts # Copy, PrivateBin, and Gist sharing - disclaimer.ts # PII warnings and legal disclaimers - main.ts # UI assembly, tabs, and event wiring + dom.ts # Shared el() DOM helper (no innerHTML) + patterns.ts # Key + value regex patterns, type guards, helpers + extract.ts # Extracts YAML from mixed console output + redact.ts # Redacts sensitive values, anonymizes paths + noise.ts # Strips auto-generated noise fields + advisories.ts # Detects misconfigurations (hardlinks, etc.) + services.ts # Parses compose object into ServiceInfo[] + UserGroupInfo + markdown.ts # GitHub + Discord markdown generators + cards.ts # Renders per-service card DOM + volume-table.ts # Service / User-Group / Volume comparison tables + volume-utils.ts # Volume parsing + matrix builder + config.ts # Customizable patterns, localStorage persistence + clipboard.ts # Copy (with execCommand fallback), PrivateBin, Gist + disclaimer.ts # PII warnings and legal disclaimers + main.ts # UI assembly, tabs, and event wiring ``` ### Testing From e80fdb7579ac735d7791a8f3459dd73323635834 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:44:48 -0500 Subject: [PATCH 08/11] chore: bump version to 0.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2ddc2e..3ce8be8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docker-compose-debugger", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docker-compose-debugger", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.1" diff --git a/package.json b/package.json index a41fc8a..5e4101f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docker-compose-debugger", - "version": "0.1.0", + "version": "0.2.0", "description": "Browser-based Docker Compose debugger — redacts secrets, shows service cards, and generates markdown tables for support channels", "type": "module", "scripts": { From 6d26d0d1403e347841b906648b3eae01c416adb6 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:54:26 -0500 Subject: [PATCH 09/11] feat(redact): add chat-platform IDs (issue #10) and fine-grained GitHub PATs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #10 (TRaSH): Discord/Slack/Telegram identifiers leak who you are and which servers/channels you operate in. Add key patterns for discord_*, slack_*, telegram_*, matrix_*, teams_* prefixes and the common *_id suffixes (guild_id, channel_id, server_id, workspace_id, tenant_id, application_id, bot_id, client_id). Tests confirm the common compose IDs (CONTAINER_ID, IMAGE_ID, USER_ID, PROCESS_ID) are not over-matched. Also fix a value-side gap: the ghp_/gho_/ghu_/ghs_/ghr_ regex only matched classic GitHub PATs, not fine-grained tokens which use the github_pat__… prefix and underscore-bearing payload. Add a dedicated alt pattern. --- src/config.ts | 2 ++ src/patterns.ts | 9 ++++++++- tests/patterns.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index becbbf4..6ac3656 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,8 @@ export const DEFAULT_CONFIG: SanitizerConfig = { 'webhook', 'pat$', '^gh[_\\-.]?(token|pat)', + '^(discord|slack|telegram|matrix|teams)[_\\-.]', + '\\b(guild|channel|server|workspace|tenant|application|bot|client)[_\\-.]?id$', ], safeKeys: [ 'PUID', 'PGID', 'TZ', 'UMASK', 'UMASK_SET', diff --git a/src/patterns.ts b/src/patterns.ts index cb16e6b..b91a84f 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -23,6 +23,11 @@ export const DEFAULT_SENSITIVE_PATTERNS: readonly RegExp[] = [ /webhook/i, /pat$/i, /^gh[_\-.]?(token|pat)/i, + // Discord / Slack / generic chat-platform identifiers. Snowflake IDs and + // channel/guild identifiers leak who the user is and which servers they + // are in; treat them as sensitive. (Issue #10, requested by TRaSH.) + /^(discord|slack|telegram|matrix|teams)[_\-.]/i, + /\b(guild|channel|server|workspace|tenant|application|bot|client)[_\-.]?id$/i, ] export const DEFAULT_SAFE_KEYS: ReadonlySet = new Set([ @@ -41,8 +46,10 @@ export const HOME_DIR_PATTERN = /^(\/home\/[^/]+|~|\/root)\// export const SENSITIVE_VALUE_PATTERNS: readonly RegExp[] = [ // Basic-auth credentials embedded in any URL (scheme then user:pass@host). /[a-z][a-z0-9+\-.]{1,20}:\/\/[^\s/@:]{1,200}:[^\s/@]{1,200}@/i, // pragma: allowlist secret - // GitHub fine-grained / classic PATs: ghp_, gho_, ghu_, ghs_, ghr_ + // GitHub classic PATs: ghp_, gho_, ghu_, ghs_, ghr_ /\bgh[pousr]_[A-Za-z0-9]{30,}\b/, + // GitHub fine-grained PATs: github_pat_ + /\bgithub_pat_[A-Za-z0-9_]{60,}\b/, // AWS access key IDs (AKIA, ASIA, AROA, AIPA, AGPA, AIDA prefixes) /\b(?:AKIA|ASIA|AROA|AIPA|AGPA|AIDA)[A-Z0-9]{16}\b/, // Tailscale auth keys diff --git a/tests/patterns.test.ts b/tests/patterns.test.ts index 899ef0a..dcc21f4 100644 --- a/tests/patterns.test.ts +++ b/tests/patterns.test.ts @@ -73,6 +73,28 @@ describe('isSensitiveKey', () => { expect(isSensitiveKey('DATABASE_URL_FILE')).toBe(true) expect(isSensitiveKey('PUID_FILE')).toBe(false) }) + + // Issue #10 (TRaSH): chat-platform IDs leak who/where you are. + it.each([ + ['GUILD_ID', true], + ['DISCORD_GUILD_ID', true], + ['DISCORD_CHANNEL_ID', true], + ['SLACK_WORKSPACE_ID', true], + ['DISCORD_BOT_TOKEN', true], + ['SLACK_TOKEN', true], + ['DISCORD_APPLICATION_ID', true], + ['BOT_ID', true], + ['DISCORD_USER_ID', true], + ])('catches chat-platform identifiers: %s', (key, expected) => { + expect(isSensitiveKey(key)).toBe(expected) + }) + + it('does not over-match bare ID-suffixed keys that are not chat platforms', () => { + expect(isSensitiveKey('CONTAINER_ID')).toBe(false) + expect(isSensitiveKey('IMAGE_ID')).toBe(false) + expect(isSensitiveKey('USER_ID')).toBe(false) + expect(isSensitiveKey('PROCESS_ID')).toBe(false) + }) }) describe('containsSensitiveValue', () => { From 777dc97731d4b55beab67e083265dbb090b6db8c Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:54:37 -0500 Subject: [PATCH 10/11] feat(extract): decode HTML entities and percent-encoded paste input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users paste from a rendered HTML source (forum thread, wiki, GitHub diff preview, or the autocompose web demo), the input arrives with &/</" entities and %20-style percent encoding instead of literal characters. YAML rejects these so the previous error path was misleading — the input was correct but the encoding wasn't. Add normalizeEncodedInput() called from extractYaml: - Decode HTML entities (named, decimal, hex) via the textarea trick when at least one entity is present. - Decode percent-encoding only when there are >= 2 percent-sequences, so a literal "100%" stays literal but "/path/My%20Files" decodes. - Malformed sequences (%ZZ) are left in place rather than throwing. 8 new tests cover plain-text passthrough, named/numeric/hex entities, percent paths, the literal-% guard, mixed encoding, and malformed input. --- src/extract.ts | 47 ++++++++++++++++++++++++++++++- tests/extract.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/extract.ts b/src/extract.ts index dac4963..06c77fb 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -6,6 +6,50 @@ export interface ExtractResult { readonly error: string | null } +const HTML_ENTITY_PATTERN = /&(amp|lt|gt|quot|#39|apos|nbsp|#x?[0-9a-f]+);/i +const PERCENT_ENCODED_PATTERN = /%[0-9a-fA-F]{2}/ + +// When users paste from a rendered HTML page (forum thread, wiki, GitHub diff +// preview, autocompose web demo), the input arrives with HTML entities and/or +// percent-encoded sequences instead of literal characters. YAML will reject +// these. Decode them up front so the rest of the pipeline sees plain text. +function decodeHtmlEntities(input: string): string { + if (typeof document === 'undefined') return input + // The textarea innerHTML trick handles named entities (&), decimal + // ("), and hex (") without exposing us to script injection — the + // value is read back as text, never inserted into the live DOM. + const ta = document.createElement('textarea') + ta.innerHTML = input + return ta.value +} + +function decodePercentEncoding(input: string): string { + // decodeURIComponent throws on malformed sequences (e.g. lone %). Decode + // each match individually so a single bad sequence doesn't drop the whole + // input. + return input.replace(/%[0-9a-fA-F]{2}/g, match => { + try { + return decodeURIComponent(match) + } catch { + return match + } + }) +} + +export function normalizeEncodedInput(raw: string): string { + let out = raw + if (HTML_ENTITY_PATTERN.test(out)) { + out = decodeHtmlEntities(out) + } + // Only apply percent-decoding when there are at least two encoded sequences + // so a stray "%2" or "%20" inside a literal string doesn't get mangled. + const matches = out.match(/%[0-9a-fA-F]{2}/g) + if (matches && matches.length >= 2 && PERCENT_ENCODED_PATTERN.test(out)) { + out = decodePercentEncoding(out) + } + return out +} + const YAML_START_KEYS = /^(version|services|name|networks|volumes|x-)[\s:]/ const SHELL_PREFIX = /^[$#>]\s|^(sudo\s|docker\s|podman\s)/ @@ -36,7 +80,8 @@ function trimTrailingPrompt(lines: readonly string[]): readonly string[] { } export function extractYaml(raw: string): ExtractResult { - const trimmed = raw.trim() + const decoded = normalizeEncodedInput(raw) + const trimmed = decoded.trim() if (trimmed === '') { return { yaml: null, error: 'No input provided. Paste your Docker Compose YAML or console output.' } } diff --git a/tests/extract.test.ts b/tests/extract.test.ts index 404ae5d..1a144ae 100644 --- a/tests/extract.test.ts +++ b/tests/extract.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { extractYaml } from '../src/extract' +import { extractYaml, normalizeEncodedInput } from '../src/extract' describe('extractYaml', () => { it('returns pure YAML as-is', () => { @@ -86,4 +86,66 @@ describe('extractYaml', () => { expect(result.yaml).toContain('services:') expect(result.error).toBeNull() }) + + it('decodes common HTML entities in pasted input', () => { + const input = + 'services:\n app:\n image: nginx\n environment:\n - FOO="bar"\n - BAZ=a&b\n' + const result = extractYaml(input) + expect(result.error).toBeNull() + expect(result.yaml).toContain('FOO="bar"') + expect(result.yaml).toContain('BAZ=a&b') + }) + + it('decodes percent-encoded paths in pasted input', () => { + const input = + 'services:\n app:\n image: nginx\n volumes:\n - /mnt/My%20Files:/data\n - /opt/foo%2Fbar:/x\n' + const result = extractYaml(input) + expect(result.error).toBeNull() + expect(result.yaml).toContain('/mnt/My Files:/data') + expect(result.yaml).toContain('/opt/foo/bar:/x') + }) + + it('leaves a single literal % alone (not a misread encoding)', () => { + const input = 'services:\n app:\n image: nginx\n environment:\n - GREET=hi 100% done\n' + const result = extractYaml(input) + expect(result.error).toBeNull() + expect(result.yaml).toContain('100% done') + }) +}) + +describe('normalizeEncodedInput', () => { + it('passes plain text through unchanged', () => { + expect(normalizeEncodedInput('plain text')).toBe('plain text') + }) + + it('decodes named HTML entities', () => { + expect(normalizeEncodedInput('a & b < c')).toBe('a & b < c') + }) + + it('decodes numeric HTML entities', () => { + expect(normalizeEncodedInput('"quote"')).toBe('"quote"') + }) + + it('decodes hex HTML entities', () => { + expect(normalizeEncodedInput('"quote"')).toBe('"quote"') + }) + + it('decodes multiple percent sequences together', () => { + expect(normalizeEncodedInput('/path/with%20spaces/and%2Fslashes')).toBe('/path/with spaces/and/slashes') + }) + + it('leaves a lone % sign alone', () => { + // Only one %-sequence and the test is conservative — keep literal. + expect(normalizeEncodedInput('battery 100% full')).toBe('battery 100% full') + }) + + it('handles malformed percent sequences gracefully', () => { + // %ZZ is not valid hex; should be left as-is rather than throwing. + expect(() => normalizeEncodedInput('value with %ZZ and %20 here %2F')).not.toThrow() + }) + + it('handles mixed HTML and percent encoding', () => { + expect(normalizeEncodedInput('foo & /a%20b')).toBe('foo & /a%20b') // percent stays — only one %20 + expect(normalizeEncodedInput('foo & /a%20b/c%20d')).toBe('foo & /a b/c d') // two %20 → decoded + }) }) From da924963283597d30ef01b1e4f24212be32d83f4 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 2 May 2026 14:54:48 -0500 Subject: [PATCH 11/11] fix: address CodeRabbit review feedback - services.ts: accept numeric user: scalars. Unquoted YAML user: 1000 parses to a number, not a string, so the previous typeof === 'string' guard silently dropped it. Centralize the coercion in readUserDirective() and use it from both extractUserGroup and deriveUser. Test added. - main.ts: collapse the markdown-preview pipeline onto the same code path the copy buttons use (buildCombinedMarkdown + formatForGitHub). Previously the preview composed its own bare table sections without the ### headings, so what users saw differed from what they copied. Drops two now-unused imports. - markdown.test.ts: add positive-path assertions that the User / Group section appears in both formatForGitHub (### heading + pipe table) and formatForDiscord (**bold** label + fenced code) when userGroup data is present. The existing tests only covered Services and Volume Comparison sections. --- src/main.ts | 18 +++++++----------- src/services.ts | 13 +++++++++++-- tests/markdown.test.ts | 33 +++++++++++++++++++++++++++++++++ tests/services.test.ts | 9 +++++++++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/main.ts b/src/main.ts index e4b3693..025f11f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,7 @@ import { copyToClipboard, openPrivateBin, openGist } from './clipboard' import { createShortNotice, createPiiWarning, createFullDisclaimer } from './disclaimer' import { el } from './dom' import { parseServices } from './services' -import { buildCombinedMarkdown, formatForDiscord, formatForGitHub, generateMarkdownTable, generateVolumeComparisonMarkdown } from './markdown' +import { buildCombinedMarkdown, formatForDiscord, formatForGitHub } from './markdown' import { renderCards } from './cards' import { renderServiceTable, renderUserGroupTable, renderVolumeTable } from './volume-table' @@ -481,25 +481,21 @@ function init(): void { volumesContainer.appendChild(volTable) } - // Markdown preview textarea - const svcMd = generateMarkdownTable(services) - const volMd = generateVolumeComparisonMarkdown(services) - const mdParts: string[] = [] - if (svcMd) mdParts.push(svcMd) - if (volMd) mdParts.push(volMd) - if (mdParts.length > 0) { - const combinedMd = mdParts.join('\n\n') + // Markdown preview — share the exact pipeline used by the copy + // buttons so what's previewed is identical to what gets copied. + const previewMd = formatForGitHub(buildCombinedMarkdown(services)) + if (previewMd) { const mdLabel = el('label') mdLabel.textContent = 'Markdown preview (GitHub format) — use the buttons above to copy GitHub or Discord variants:' mdLabel.style.marginTop = '0.75rem' volumesContainer.appendChild(mdLabel) const mdPreview = el('textarea', { className: 'code-textarea', - rows: String(Math.min(combinedMd.split('\n').length + 1, 18)), + rows: String(Math.min(previewMd.split('\n').length + 1, 18)), readonly: 'true', spellcheck: 'false', }) - mdPreview.value = combinedMd + mdPreview.value = previewMd volumesContainer.appendChild(mdPreview) } } diff --git a/src/services.ts b/src/services.ts index 0d173a1..9991e3e 100644 --- a/src/services.ts +++ b/src/services.ts @@ -130,11 +130,20 @@ function envLookupCI(env: ReadonlyMap, name: string): string { return '' } +// Compose accepts user as either a quoted string ("1000:1000") or a bare YAML +// scalar (1000). js-yaml parses the bare form to a number, so coerce both. +function readUserDirective(service: Record): string { + const v = service['user'] + if (typeof v === 'string') return v.trim() + if (typeof v === 'number') return String(v) + return '' +} + function extractUserGroup(service: Record, env: ReadonlyMap): UserGroupInfo { const groupAddRaw = service['group_add'] const groupAdd = Array.isArray(groupAddRaw) ? groupAddRaw.map(String) : [] return { - user: typeof service['user'] === 'string' ? service['user'].trim() : '', + user: readUserDirective(service), puid: envLookupCI(env, 'PUID'), pgid: envLookupCI(env, 'PGID'), groupAdd, @@ -143,7 +152,7 @@ function extractUserGroup(service: Record, env: ReadonlyMap, env: ReadonlyMap): string { - const directive = typeof service['user'] === 'string' ? service['user'].trim() : '' + const directive = readUserDirective(service) const puid = envLookupCI(env, 'PUID') const pgid = envLookupCI(env, 'PGID') diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts index 2d6c607..771ca20 100644 --- a/tests/markdown.test.ts +++ b/tests/markdown.test.ts @@ -215,6 +215,23 @@ describe('formatForGitHub', () => { expect(result).toContain('### Services') expect(result).not.toContain('### Volume Comparison') }) + + it('includes User / Group section when userGroup data is present', () => { + const services = [ + makeService({ + name: 'app', + image: 'nginx', + userGroup: { user: '1000:1000', puid: '1000', pgid: '1000', groupAdd: ['video'], umask: '022' }, + }), + ] + const result = formatForGitHub(buildCombinedMarkdown(services)) + expect(result).toContain('### User / Group') + expect(result).toContain('| User / Group | app |') + expect(result).toContain('| user: | 1000:1000 |') + expect(result).toContain('| PUID | 1000 |') + expect(result).toContain('| group_add | video |') + expect(result).toContain('| UMASK | 022 |') + }) }) describe('formatForDiscord', () => { @@ -256,4 +273,20 @@ describe('formatForDiscord', () => { expect(result).toContain('**Services**') expect(result).not.toContain('**Volume Comparison**') }) + + it('includes User / Group section wrapped in fenced code', () => { + const services = [ + makeService({ + name: 'app', + image: 'nginx', + userGroup: { user: '1000:1000', puid: '', pgid: '', groupAdd: [], umask: '' }, + }), + ] + const result = formatForDiscord(buildCombinedMarkdown(services)) + expect(result).toContain('**User / Group**\n```\n') + expect(result).toContain('| user: | 1000:1000 |') + // Three sections expected: Services + User/Group (volumes omitted, no volumes data) + const fences = (result.match(/```/g) ?? []).length + expect(fences).toBe(4) + }) }) diff --git a/tests/services.test.ts b/tests/services.test.ts index b77182a..2c83a7a 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -510,6 +510,15 @@ describe('parseServices', () => { umask: '', }) }) + + it('coerces numeric user: scalars (unquoted YAML) to string', () => { + // Unquoted `user: 1000` parses as a number; the directive should still + // surface in the user field rather than being silently dropped. + const compose = { services: { app: { image: 'app', user: 1000 } } } + const result = parseServices(compose) + expect(result[0].userGroup.user).toBe('1000') + expect(result[0].extras.get('user')).toBe('1000') + }) }) // Immutability