Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,26 @@
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
},
// Playwright + browsers + Linux runtime libs in one shot. The
// feature's install.sh runs `npx playwright install --with-deps`
// as the remote user, so the browser binaries land in
// /home/vscode/.cache/ms-playwright/ and the apt-side runtime
// libs (libnss3, libgbm1, etc.) are pulled in too. Reproducible
// and dramatically simpler than maintaining the dep list in our
// Dockerfile.
"ghcr.io/schlich/devcontainer-features/playwright:0": {
"browsers": "chromium"
}
},
// Runs on the HOST before the container is created/started. Picks a
// free TCP port, writes it to .devcontainer/ports.env. The container
// runs with --network host (see runArgs) so the same port number is
// both the in-container bind and the host-visible address — no port
// forwarding involved. See the wiki page
// [[preferences/devcontainer-ports]] for the full rationale and the
// consumer recipes (launch.json, tasks.json, host scripts).
"initializeCommand": ".devcontainer/initializeCommand.sh",
"postCreateCommand": "go version && node --version",
"postAttachCommand": "npm install --prefix webui",
"customizations": {
Expand All @@ -26,7 +44,10 @@
"ethan-reesor.vscode-go-test-adapter",
"idered.npm",
"qwtel.sqlite-viewer",
"dbaeumer.vscode-eslint"
"dbaeumer.vscode-eslint",
// shellCommand.execute input type, used by launch.json to read
// .devcontainer/ports.env into the Chrome launch URL.
"augustocdias.tasks-shell-input"
],
"settings": {
"go.buildTags": "",
Expand All @@ -36,7 +57,19 @@
}
}
},
"appPort": ["127.0.0.1:51888:4242"],
// No appPort: --network host below means no port forwarding is
// involved, so there's no host:container mapping to specify. The
// server binds directly on the host's network namespace at whatever
// port initializeCommand picked.
//
// --env-file injects MIND_MAP_HOST_PORT into the container so the
// mind-map binary (which reads it via `serve --addr`) listens on
// the right port. host-side consumers (launch.json, scripts) read
// the same file directly.
"runArgs": [
"--network", "host",
"--env-file", ".devcontainer/ports.env"
],
"portsAttributes": {
"4242": {
"label": "mind-map Server (devcontainer)",
Expand Down
56 changes: 56 additions & 0 deletions .devcontainer/initializeCommand.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Runs on the HOST (not in the container) before docker run. Picks a
# free TCP port for the mind-map server and writes it to
# .devcontainer/ports.env. The value is consumed in two places:
#
# 1. Inside the container, via runArgs --env-file, so the mind-map
# binary's `serve --addr` reads $MIND_MAP_HOST_PORT.
# 2. On the host, by tools (launch.json, the screenshot harness,
# ad-hoc curl) that need to know what URL to hit.
#
# Because the container runs with --network host (see devcontainer.json
# runArgs), there is no port forwarding involved — the container binds
# directly to the host's network namespace, so the same port number is
# the host port. That's why we don't need ${localEnv:...} substitution
# in appPort (which doesn't work for values produced by
# initializeCommand anyway, since appPort substitution happens before
# initializeCommand runs).
#
# See: [[preferences/devcontainer-ports]] in the mind-map wiki for the
# full pattern and the rationale.
set -euo pipefail

# Preferred starting points. The value is stable across normal runs
# when the preferred slot is free, so browser history / bookmarks /
# muscle memory keep working. Only drifts when there's a real
# collision (another worktree's devcontainer, a stray host process,
# a VS Code port-forwarding daemon squatting on it).
PREFERRED=(51888 51889 51890 51891 51892 51893)

pick_port() {
local p
for p in "${PREFERRED[@]}"; do
if ! ss -tln "sport = :$p" 2>/dev/null | grep -q LISTEN; then
echo "$p"
return
fi
done
# Kernel-assigned fallback. Bind a socket to port 0, read what we
# got, close it. Tiny TOCTOU window before the container claims it;
# in practice we don't hit it.
python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()'
}

PORT=$(pick_port)

# `cd` so the relative path is stable regardless of where the
# devcontainer CLI was invoked from.
cd "$(dirname "$0")"

# `--env-file` (used in runArgs) accepts bare KEY=VALUE lines. Don't
# quote the value — docker chokes on that.
cat > ports.env <<EOF
MIND_MAP_HOST_PORT=$PORT
EOF

echo "devcontainer host port: $PORT (written to .devcontainer/ports.env)"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ webui/node_modules/
.vscode/cache/
__debug_bin*
*.exe

# Devcontainer host-port pick. Written by .devcontainer/initializeCommand.sh
# at container-create time. Host-specific + per-run, so it must never be
# committed. See the wiki page preferences/devcontainer-ports for the
# pattern.
.devcontainer/ports.env
37 changes: 34 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@
],
"configurations": [
{
// The devcontainer runs with --network host (see
// .devcontainer/devcontainer.json), so the in-container
// port IS the host port. initializeCommand.sh picks the
// value at create time and writes it to
// .devcontainer/ports.env; --env-file in runArgs makes
// $MIND_MAP_HOST_PORT available in the container's env.
// See [[preferences/devcontainer-ports]].
"name": "mind-map Server",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/mind-map",
"args": ["serve", "--addr", "0.0.0.0:4242", "--webui", "${workspaceFolder}/webui/dist"],
"args": ["serve", "--addr", "0.0.0.0:${env:MIND_MAP_HOST_PORT}", "--webui", "${workspaceFolder}/webui/dist"],
"preLaunchTask": "build-webui"
},
{
Expand All @@ -26,7 +33,7 @@
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/mind-map",
"args": ["serve", "--addr", "0.0.0.0:4242", "--webui", "${workspaceFolder}/webui/dist"],
"args": ["serve", "--addr", "0.0.0.0:${env:MIND_MAP_HOST_PORT}", "--webui", "${workspaceFolder}/webui/dist"],
},
{
"name": "mind-map (stdio)",
Expand All @@ -37,12 +44,16 @@
"args": ["serve", "--stdio"],
},
{
// Same port the server binds to (host == container under
// --network host). The mindMapHostPort input reads
// .devcontainer/ports.env so the URL tracks whatever value
// initializeCommand picked.
"name": "WebUI",
"type": "chrome",
"request": "launch",
"browserLaunchLocation": "ui",
"runtimeExecutable": "stable",
"url": "http://localhost:51888",
"url": "http://localhost:${input:mindMapHostPort}",
"webRoot": "${workspaceFolder}/webui",
"preLaunchTask": "waitForServer",
"userDataDir": "${workspaceFolder}/.vscode/cache",
Expand All @@ -58,5 +69,25 @@
"mode": "test",
"program": "${workspaceFolder}/internal/wiki",
}
],
"inputs": [
{
// Reads MIND_MAP_HOST_PORT out of .devcontainer/ports.env
// every time the WebUI launch is invoked. Evaluated lazily,
// so even if the container is rebuilt and the port changes
// (because initializeCommand picked something else), the
// next launch picks up the new value with no manual edit.
//
// Requires the augustocdias.tasks-shell-input extension,
// which is declared in .devcontainer/devcontainer.json so
// contributors get it automatically.
"id": "mindMapHostPort",
"type": "command",
"command": "shellCommand.execute",
"args": {
"command": "grep ^MIND_MAP_HOST_PORT= ${workspaceFolder}/.devcontainer/ports.env | cut -d= -f2 | tr -d '\\n'",
"useSingleResult": true
}
}
]
}
12 changes: 11 additions & 1 deletion .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,19 @@
"problemMatcher": ["$go"]
},
{
// Probes the port the server is bound to. The container
// runs with --network host (see devcontainer.json), so
// the host and container see the same port number —
// whatever value initializeCommand.sh wrote to
// .devcontainer/ports.env and propagated into the
// container via --env-file. Sourcing the file in the
// shell command is the simplest portable way to read it;
// it avoids the need for the tasks-shell-input extension
// for tasks (the input is only used by launch.json's
// Chrome URL). See [[preferences/devcontainer-ports]].
"label": "waitForServer",
"type": "shell",
"command": "while ! nc -z localhost 4242; do sleep 1; done",
"command": "set -a; source ${workspaceFolder}/.devcontainer/ports.env; set +a; while ! nc -z localhost \"$MIND_MAP_HOST_PORT\"; do sleep 1; done",
"group": "none",
"dependsOn": ["build-webui"],
"problemMatcher": {
Expand Down
55 changes: 54 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ type SyncMapping struct {
Prefix string `json:"prefix"`
Remote string `json:"remote"`
Direction SyncDirection `json:"direction,omitempty"`
// LFS, when true, configures the synced shadow clone to track
// the patterns in LFSPatterns via git-lfs. Useful when binary
// assets (uploaded via the image-support tools) would otherwise
// balloon the git repo. Requires git-lfs on the host. Defaults
// off because GitHub wikis don't support LFS — flip it on only
// for plain repos / providers that do.
LFS bool `json:"lfs,omitempty"`
// LFSPatterns is the list of .gitattributes patterns to route
// through LFS. If empty when LFS is true, a sensible default
// (the browser-renderable image extensions plus .pdf) is used
// — see DefaultLFSPatterns.
LFSPatterns []string `json:"lfs_patterns,omitempty"`
}

// DefaultLFSPatterns returns the default set of file patterns to route
// through LFS when a sync mapping enables LFS but doesn't override the
// patterns explicitly. Tracks the browser-renderable image set used by
// the upload tools, plus common companion formats agents are likely to
// reach for next.
func DefaultLFSPatterns() []string {
return []string{
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp",
"*.avif", "*.svg", "*.bmp", "*.ico",
}
}

// SyncConfig holds git sync settings.
Expand Down Expand Up @@ -90,7 +114,8 @@ func (s *SyncConfig) ResolveRemote(pagePath string) string {
// If a mapping for prefix already exists, its remote and direction are
// both replaced — this is treated as a re-registration, not an additive
// op, so an existing mapping switching from bidirectional to pull-only
// (or vice versa) propagates cleanly.
// (or vice versa) propagates cleanly. LFS settings on an existing
// mapping are preserved; use AddMappingWithLFS to update them.
func (s *SyncConfig) AddMapping(prefix, remote string, direction SyncDirection) {
direction = direction.Normalize()
for i, m := range s.Mappings {
Expand All @@ -103,6 +128,34 @@ func (s *SyncConfig) AddMapping(prefix, remote string, direction SyncDirection)
s.Mappings = append(s.Mappings, SyncMapping{Prefix: prefix, Remote: remote, Direction: direction})
}

// AddMappingWithLFS is like AddMapping but also sets the LFS flag and
// (optionally) the LFS patterns. Patterns default to DefaultLFSPatterns
// when LFS is true and patterns is nil. Pass an empty (non-nil) slice
// to explicitly track nothing — that's a usable no-op state for an
// operator who wants to flip LFS on later.
func (s *SyncConfig) AddMappingWithLFS(prefix, remote string, direction SyncDirection, lfs bool, patterns []string) {
direction = direction.Normalize()
if lfs && patterns == nil {
patterns = DefaultLFSPatterns()
}
for i, m := range s.Mappings {
if m.Prefix == prefix {
s.Mappings[i].Remote = remote
s.Mappings[i].Direction = direction
s.Mappings[i].LFS = lfs
s.Mappings[i].LFSPatterns = patterns
return
}
}
s.Mappings = append(s.Mappings, SyncMapping{
Prefix: prefix,
Remote: remote,
Direction: direction,
LFS: lfs,
LFSPatterns: patterns,
})
}

// Remotes returns all unique remotes (default + mappings).
func (s *SyncConfig) Remotes() []string {
seen := make(map[string]bool)
Expand Down
Loading