From cd6544aedb994430a578396b9ab603815c8a601a Mon Sep 17 00:00:00 2001 From: jarek Date: Thu, 1 Jan 2026 16:00:34 +0100 Subject: [PATCH] 1.0.5 --- Dockerfile | 2 +- docker-entrypoint.sh | 54 + src/images/logo.webp | Bin 9926 -> 0 bytes src/lib/components/CodeEditor.svelte | 117 +- src/lib/components/PullTab.svelte | 7 +- src/lib/components/StackEnvVarsEditor.svelte | 2 +- src/lib/components/StackEnvVarsPanel.svelte | 404 +++++-- src/lib/data/changelog.json | 20 + src/lib/server/auth.ts | 129 +- src/lib/server/db.ts | 2 + src/lib/server/db/schema/index.ts | 2 +- src/lib/server/db/schema/pg-schema.ts | 2 +- src/lib/server/docker.ts | 34 +- src/lib/server/git.ts | 22 +- src/lib/server/notifications.ts | 3 + src/lib/server/stacks.ts | 190 ++- .../server/subprocesses/event-subprocess.ts | 19 +- src/routes/api/environments/+server.ts | 1 + src/routes/api/environments/[id]/+server.ts | 1 + src/routes/api/stacks/+server.ts | 17 +- src/routes/api/stacks/[name]/env/+server.ts | 164 ++- src/routes/api/users/+server.ts | 10 +- src/routes/api/users/[id]/mfa/+server.ts | 22 +- src/routes/containers/+page.svelte | 16 +- .../containers/CreateContainerModal.svelte | 1040 ++--------------- .../containers/EditContainerModal.svelte | 957 ++++++--------- src/routes/login/+page.svelte | 7 +- src/routes/logs/+page.svelte | 3 +- src/routes/profile/+page.svelte | 32 +- src/routes/profile/MfaSetupModal.svelte | 179 ++- .../settings/auth/users/UserModal.svelte | 2 +- .../notifications/NotificationModal.svelte | 16 +- src/routes/stacks/+page.svelte | 120 +- src/routes/stacks/GitStackModal.svelte | 41 +- src/routes/stacks/StackModal.svelte | 242 +++- 35 files changed, 1892 insertions(+), 1987 deletions(-) delete mode 100644 src/images/logo.webp diff --git a/Dockerfile b/Dockerfile index 6f8aa0d..9a08239 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Copy emergency scripts (only the emergency subfolder, not license generation scripts) COPY scripts/emergency/ ./scripts/ -RUN chmod +x ./scripts/*.sh 2>/dev/null || true +RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true # Create directories with proper ownership RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \ diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ecbc9b2..3f00ec4 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -12,6 +12,60 @@ if [ "$(id -u)" = "0" ]; then RUNNING_AS_ROOT=true fi +# === Non-root mode (user: directive in compose) === +# If container started as non-root, skip all user management and run directly +if [ "$RUNNING_AS_ROOT" = "false" ]; then + echo "Running as user $(id -u):$(id -g) (set via container user directive)" + + # Ensure data directories exist (user must have write access to DATA_DIR via volume mount) + DATA_DIR="${DATA_DIR:-/app/data}" + if [ ! -d "$DATA_DIR/db" ]; then + echo "Creating database directory at $DATA_DIR/db" + mkdir -p "$DATA_DIR/db" 2>/dev/null || { + echo "ERROR: Cannot create $DATA_DIR/db directory" + echo "Ensure the data volume is mounted with correct permissions for user $(id -u):$(id -g)" + echo "" + echo "Example docker-compose.yml:" + echo " volumes:" + echo " - ./data:/app/data # This directory must be writable by user $(id -u)" + exit 1 + } + fi + if [ ! -d "$DATA_DIR/stacks" ]; then + mkdir -p "$DATA_DIR/stacks" 2>/dev/null || true + fi + + # Check Docker socket access if mounted + SOCKET_PATH="/var/run/docker.sock" + if [ -S "$SOCKET_PATH" ]; then + if test -r "$SOCKET_PATH" 2>/dev/null; then + echo "Docker socket accessible at $SOCKET_PATH" + # Detect hostname from Docker if not set + if [ -z "$DOCKHAND_HOSTNAME" ]; then + DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p') + if [ -n "$DETECTED_HOSTNAME" ]; then + export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME" + echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME" + fi + fi + else + SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown") + echo "WARNING: Docker socket not readable by user $(id -u)" + echo "Add --group-add $SOCKET_GID to your docker run command" + fi + else + echo "No Docker socket found at $SOCKET_PATH" + echo "Configure Docker environments via the web UI (Settings > Environments)" + fi + + # Run directly as current user (no su-exec needed) + if [ "$1" = "" ]; then + exec bun run ./build/index.js + else + exec "$@" + fi +fi + # === User Setup === # Root mode: PUID=0 requested OR already running as root with default PUID/PGID if [ "$PUID" = "0" ]; then diff --git a/src/images/logo.webp b/src/images/logo.webp deleted file mode 100644 index 421abdfeef7973d5afb18896fb1c0e4f5ca42ad8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9926 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 Oi>LdcnoS27AOHYp<97%E diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index f49406f..4cac179 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -3,11 +3,51 @@ import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; - import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language'; + import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; + // Simple dotenv/env file language parser + const dotenvParser: StreamParser<{ inValue: boolean }> = { + startState() { + return { inValue: false }; + }, + token(stream, state) { + // Start of line + if (stream.sol()) { + state.inValue = false; + // Skip leading whitespace + stream.eatSpace(); + // Comment line + if (stream.peek() === '#') { + stream.skipToEnd(); + return 'comment'; + } + } + // If in value part, consume the rest + if (state.inValue) { + stream.skipToEnd(); + return 'string'; + } + // Variable name before = + if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)) { + if (stream.peek() === '=') { + return 'variableName.definition'; + } + return 'variableName'; + } + // Equals sign - switch to value mode + if (stream.eat('=')) { + state.inValue = true; + return 'operator'; + } + // Skip anything else + stream.next(); + return null; + } + }; + // Docker Compose keywords for autocomplete const COMPOSE_TOP_LEVEL = ['services', 'networks', 'volumes', 'configs', 'secrets', 'name', 'version']; @@ -453,6 +493,9 @@ case 'sh': // No dedicated shell/dockerfile support, use basic highlighting return []; + case 'dotenv': + case 'env': + return StreamLanguage.define(dotenvParser); default: return []; } @@ -542,6 +585,13 @@ // Track if we're initialized (prevents multiple createEditor calls) let initialized = false; + // Debounce timer for marker updates (prevents flicker during fast typing) + let markerUpdateTimer: ReturnType | null = null; + const MARKER_UPDATE_DEBOUNCE_MS = 300; + + // Track last applied markers to avoid redundant updates + let lastAppliedMarkersJson = ''; + function createEditor() { if (!container || view || initialized) return; initialized = true; @@ -551,12 +601,14 @@ : [dockhandLight, syntaxHighlighting(defaultHighlightStyle)]; // Build autocompletion config - add Docker Compose completions for YAML + // Note: activateOnTyping can interfere with key repeat, so we disable it + // Users can still trigger autocomplete manually with Ctrl+Space const autocompletionConfig = language === 'yaml' ? autocompletion({ override: [composeCompletions, composeValueCompletions], - activateOnTyping: true + activateOnTyping: false }) - : autocompletion(); + : autocompletion({ activateOnTyping: false }); const extensions = [ lineNumbers(), @@ -594,18 +646,25 @@ extensions }); - // Custom transaction handler - this is SYNCHRONOUS and more reliable than updateListener + // Custom transaction handler - applies transactions synchronously but defers callback // Based on the Svelte Playground pattern: https://svelte.dev/playground/91649ba3e0ce4122b3b34f3a95a00104 const dispatchTransactions = (trs: readonly import('@codemirror/state').Transaction[]) => { if (!view) return; - // Apply all transactions + // Apply all transactions synchronously (required by CodeMirror) view.update(trs); // Check if any transaction changed the document const lastChangingTr = trs.findLast(tr => tr.docChanged); if (lastChangingTr && onchangeRef) { - onchangeRef(lastChangingTr.newDoc.toString()); + // Defer callback to next microtask to avoid blocking input handling + // This allows key repeat to work properly + const newContent = lastChangingTr.newDoc.toString(); + queueMicrotask(() => { + if (onchangeRef) { + onchangeRef(newContent); + } + }); } }; @@ -615,7 +674,6 @@ dispatchTransactions }); - // Push initial markers if provided if (variableMarkers.length > 0) { view.dispatch({ @@ -625,11 +683,16 @@ } function destroyEditor() { + if (markerUpdateTimer) { + clearTimeout(markerUpdateTimer); + markerUpdateTimer = null; + } if (view) { view.destroy(); view = null; } initialized = false; + lastAppliedMarkersJson = ''; } // Get current editor content @@ -656,11 +719,35 @@ } // Update variable markers - this is the key method for parent to call - export function updateVariableMarkers(markers: VariableMarker[]) { - if (view) { - view.dispatch({ - effects: updateMarkersEffect.of(markers) - }); + // Debounced to prevent flicker during fast typing + export function updateVariableMarkers(markers: VariableMarker[], immediate = false) { + if (!view) return; + + // Check if markers actually changed (compare by content, not reference) + const newJson = JSON.stringify(markers); + if (newJson === lastAppliedMarkersJson) { + return; // No change, skip update + } + + // Clear any pending update + if (markerUpdateTimer) { + clearTimeout(markerUpdateTimer); + markerUpdateTimer = null; + } + + const applyUpdate = () => { + if (view) { + lastAppliedMarkersJson = newJson; + view.dispatch({ + effects: updateMarkersEffect.of(markers) + }); + } + }; + + if (immediate) { + applyUpdate(); + } else { + markerUpdateTimer = setTimeout(applyUpdate, MARKER_UPDATE_DEBOUNCE_MS); } } @@ -693,12 +780,11 @@ }); // Update markers when prop changes (backup mechanism, parent should also call updateVariableMarkers) + // Uses the debounced update to prevent flicker during fast typing $effect(() => { const markers = variableMarkers; if (view && markers) { - view.dispatch({ - effects: updateMarkersEffect.of(markers) - }); + updateVariableMarkers(markers); } }); @@ -706,7 +792,6 @@
e.stopPropagation()} >