Development Guide

Prerequisites

Tool Version Notes
Node.js v20+ (v22 recommended)  
Go v1.22+ CGO must be enabled
C compiler any Required by CGO — gcc on Linux, Xcode CLT on macOS, MinGW on Windows
Trivy CLI optional For image vulnerability scanning in Security Hub

Install Trivy (macOS):

brew install trivy

Setup

# Install Node dependencies (also runs postinstall which rebuilds node-pty)
npm install

# Build the Go sidecar — required before first dev run
cd go-core && go build ./cmd/podscape-core/ && cd ..

Development

npm run dev          # Start Electron + Vite dev server with hot reload

Note: If window.kubectl.* methods appear as undefined at runtime, the preload build is stale. Restart npm run dev.


Codebase Structure

Renderer (src/renderer/)

  • components/core/: High-level orchestration components (Layout, SectionRouter, OverlayManager, ErrorBoundary).
  • store/: Zustand state management split into domain-specific slices.
  • types/: Granular TypeScript definitions (api.ts, k8s.ts, ui.ts, utils.ts, common.ts).
  • config.ts: Centralized UI configuration — LIST_SECTIONS, CLUSTER_SCOPED_SECTIONS, PROVIDER_SECTIONS, SECTION_LABELS, COLUMNS — all typed with ResourceKind. Adding a new section requires only updating this file and the appropriate dispatch map in SectionRouter.
  • utils/prefetch.ts: Background eager-loads lazy panel chunks after mount; failures are logged as warnings rather than swallowed silently.

Go Sidecar (go-core/)

  • internal/handlers/: HTTP resource handlers and operation logic.
  • internal/k8sutil/: Canonical Kubernetes resource metadata — KindGVR, KindGVRFallback, ClusterScopedKinds. This is the single source of truth for GVR resolution; internal/ops and internal/handlers/operations.go both delegate here. Adding a new resource type or alias requires only a change in this package.
  • internal/rbac/: Core RBAC probing engine.

Architecture patterns

Stale-context guard

Every async action that touches the store must capture the active context at call time and discard results when the context has changed mid-flight:

const ctx = get().selectedContext
// ... await something expensive ...
if (get().selectedContext !== ctx) return   // discard stale result

A context-string comparison alone can be bypassed by rapid A→B→A switching (the result from B resolves when the context is back to A, so the guard passes). Monotonic sequence counters prevent this:

let mySeq = ++sliceSeq       // module-level counter
// ... await something expensive ...
if (mySeq !== sliceSeq || get().selectedContext !== ctx) return

Both checks are required. providersSlice, analysisSlice, and clusterSlice all use this pattern.

Port-forward race guard

portForward and stopPortForward can race: stop may arrive while the initial sidecar HTTP request is still in flight. The pendingStops: Set<string> in src/main/ipc/kubectl.ts bridges the gap — stopPortForward records the id before calling the sidecar, and portForward discards the alive-poll timer immediately if the id is already pending. Without this, a timer is started and never cleared.

SSE stream resolution (settled flag)

The handleScanResponse helper in src/main/ipc/kubectl.ts resolves or rejects the wrapping Promise via SSE result / error events. A settled boolean is set when either terminal event fires. The end listener rejects if !settled — this surfaces unexpected stream closes (sidecar crash, network reset) as real errors rather than silent null results.

Gorilla WebSocket Concurrency Safety

Gorilla WebSocket (github.com/gorilla/websocket) does not allow concurrent write operations or concurrent read operations on the same connection.

  • Writes: outgoing frames (stdout/stderr goroutines writing concurrently) must be synchronized using a sync.Mutex inside the wsStream.Write helper.
  • Reads (Stdin): To avoid concurrent reads, a single read-pump goroutine is established to read incoming frames from the WebSocket and write them to an io.Pipe. The execution handler (exec.Exec) reads stdin from the other end of the pipe.

Double-Checked Locking (DCL) on Managers

For operations involving slow I/O or network calls (such as loading Helm index files from disk or parsing Kubernetes client configurations), avoid holding a global manager lock. Instead, use Double-Checked Locking (DCL):

  1. Acquire the lock, check if the value is in the cache, and return immediately if found.
  2. Release the lock before performing the slow operation (so concurrent commands are not blocked).
  3. Perform the disk/network operation.
  4. Re-acquire the lock, verify that no other goroutine has populated the cache entry in the meantime, write the result, and unlock.

Atomic Settings Configuration Writes

To prevent corrupted or empty settings files on sudden process termination or write races:

  • Write the configuration string to a temporary file (settings.json.tmp) first, then rename it atomically to settings.json.
  • Serialize concurrent saves in the main process using a promise queue (writeLock = writeLock.then(...)) to prevent interleaved read-modify-write sequences from clobbering each other.

Go rest.Config Shallow Copying

The NewSPDYExecutor method in client-go (k8s.io/client-go/tools/remotecommand) modifies the passed *rest.Config pointer in-place to set the negotiated serializer. If multiple executions (terminals/logs) run concurrently, they will race on the shared config pointer. Always shallow-copy the configuration (cfgCopy := *config) before creating a new executor:

cfgCopy := *config
executor, err := remotecommand.NewSPDYExecutor(&cfgCopy, "POST", req.URL())

Topology Dangling Edge Pruning

When building topology maps, check that both Source and Target endpoints of an edge exist in the list of built nodes (topo.Nodes). Any dangling edge (which can occur due to namespace filtering or RBAC exclusions of resources like services/PVCs) will cause undefined pointer dereferences and crashes in the frontend layout. Prune these edges during the final assembly step.


Running Tests

# Frontend (Vitest)
npm run test
npm run test:watch   # watch mode

# Go sidecar (handlers, rbac, helm, portforward, prometheus, ownerchain)
cd go-core && go test ./...

Slice test conventions

Store slices are tested in isolation using vi.fn() set/get pairs — no Zustand store needed. Every slice test file must call setupMocks() from src/renderer/store/slices/test-utils.ts before any import that references window:

import { setupMocks } from './test-utils'
const { windowMock } = setupMocks()
import { createMySlice } from './mySlice'

setupMocks() wires window.kubectl, window.exec, window.settings, localStorage, and document. If a new window.* namespace is added to the preload, add it to the windowMock object in test-utils.ts so all existing slice tests continue to work without per-test boilerplate.

The window.exec.kill mock is included (vi.fn().mockResolvedValue(undefined)). Any test for closeExecTab or closeExec will call it automatically — assert on windowMock.exec.kill if the test needs to verify the call.


Building

The full build compiles the Go sidecar first, then runs electron-vite:

npm run build        # Go sidecar + renderer + main + preload

Individual steps:

cd go-core && go build ./cmd/podscape-core/   # sidecar binary only
cd go-core && go build ./cmd/podscape-mcp/    # MCP server binary only
npx electron-vite build                        # Electron assets only

Distribution

Packages are produced by electron-builder. Artifacts land in dist/.

npm run build:mac    # macOS — .dmg + .zip (arm64 and x64 separately)
npm run build:win    # Windows — NSIS installer (.exe)
npm run build:linux  # Linux — AppImage + .deb

Icon generation

Icons must be generated before packaging if they don’t already exist:

npm run icon:icns    # macOS .icns from resources/icon.png
npm run icon:ico     # Windows .ico from resources/icon.png

Requirements: imagemagick (Linux/macOS) or the scripts handle it via sips on macOS.


CI / Release

Releases are triggered by pushing a v* git tag. The workflow (.github/workflows/release.yml) runs three parallel jobs:

Job Runner Output
release-mac (arm64) macos-latest .dmg + .zip for Apple Silicon
release-mac (x64) macos-13 .dmg + .zip for Intel
release-win windows-latest .exe NSIS installer
release-linux ubuntu-latest .AppImage + .deb

Each job also uploads a checksums-<platform>.txt file to the GitHub release so users can verify downloads with SHA256.

Required GitHub secret: GH_TOKEN with contents: write permission on the repository.


Settings Schema

App settings are stored in ~/.podscape/settings.json. The file is created automatically on first write. All fields are optional — missing keys fall back to the defaults shown below.

{
  "kubeconfigPath": "",
  "shellPath": "",
  "theme": "dark",
  "prodContexts": [],
  "prometheusUrls": {},
  "tourCompleted": false,
  "pluginsEnabled": true,
  "gitopsEnabled": true,
  "networkEnabled": true
}
Field Type Default Description
kubeconfigPath string "" Absolute path to a kubeconfig file. Empty string means use $KUBECONFIG env var, then ~/.kube/config.
shellPath string "" Absolute path to the shell binary used for PTY terminals (e.g. /bin/zsh). Empty string means auto-detect from the user’s environment.
theme "dark" \| "light" \| "" "dark" UI colour theme. Empty string defers to the last-used or OS preference.
prodContexts string[] [] List of kubeconfig context names to treat as production. Any matching context activates the red border + banner in the UI.
prometheusUrls Record<string, string> {} Per-context manual Prometheus base URLs (e.g. { "my-ctx": "https://prometheus.example.com" }). Empty string for a context means auto-discover via Kubernetes service proxy.
tourCompleted boolean false Whether the post-connection onboarding tour has been shown and dismissed.
pluginsEnabled boolean true Show the Plugins (Krew) panel in the sidebar.
gitopsEnabled boolean true Show the GitOps panel in the sidebar.
networkEnabled boolean true Show the Network Map and Connectivity Tester panels in the sidebar.

The settings file is read and written by src/main/settings/settings_storage.ts (getSettings / saveSettings). IPC handlers in src/main/ipc/settings.ts expose settings:get and settings:set channels to the renderer via window.settings.


Auto-Updater

Podscape uses electron-updater to deliver in-app updates. The updater is configured in src/main/system/updater.ts and only activates in production builds (is.dev guard — no update checks during npm run dev).

Update source: electron-updater reads the publish configuration from package.json (GitHub releases). It fetches latest.yml / latest-mac.yml from the GitHub release assets to compare the current version against the latest published release.

Behaviour:

  • autoDownload is set to false — updates are downloaded only when the user explicitly confirms.
  • autoInstallOnAppQuit is true — a downloaded update is installed automatically the next time the app quits.
  • An initial check fires 5 seconds after launch (delayed to avoid racing with sidecar startup). The timer is cancelled on before-quit to prevent a network request into a partially torn-down process.
  • Events fired before the renderer window is ready are queued (up to 20) and flushed once did-finish-load fires, so no update notification is lost on a fast machine.

IPC channels:

Channel Direction Description
updater:check Renderer → Main (handle) Trigger an immediate update check
updater:download Renderer → Main (handle) Start downloading the available update
updater:install Renderer → Main (handle) Quit and install the downloaded update
updater:checking Main → Renderer (send) Update check started
updater:available Main → Renderer (send) New version found; payload is the UpdateInfo object
updater:not-available Main → Renderer (send) Already on the latest version
updater:progress Main → Renderer (send) Download progress; payload is a ProgressInfo object
updater:downloaded Main → Renderer (send) Download complete; payload is the UpdateDownloadedEvent object
updater:error Main → Renderer (send) Error during check or download; payload is the error message string

The UpdateBanner component in src/renderer/components/core/UpdateBanner.tsx listens for these events and renders an in-app notification bar when an update is available or has been downloaded.

Testing updates locally: electron-updater skips the update check entirely when is.dev is true, so local testing requires a production build. Point autoUpdater.updateConfigPath to a local dev-app-update.yml file or use autoUpdater.forceDevUpdateConfig = true with a matching release on a local file server.


Known Build Notes

  • Native module rebuild: npm install runs electron-builder install-app-deps via postinstall, which rebuilds any native modules for the target Electron version.
  • CGO on Windows: MinGW must be on PATH before building the Go sidecar. The CI workflow adds it via $env:PATH.
  • Sidecar location: In dev, the binary is expected at go-core/podscape-core. In production, electron-builder copies it to resources/bin/podscape-core via extraResources.
  • Stale preload: If window.kubectl.* (or window.krew.*) methods appear as undefined at runtime, the preload build is stale. Restart npm run dev to pick up the latest preload.

Kubectl Plugin Development

The Plugin Panel (src/renderer/components/plugins/) uses a registry-driven architecture. Each plugin is a self-contained module with an InfoPanel and a RunPanel.

File layout

src/renderer/components/plugins/
  <plugin-name>/
    InfoPanel.tsx      # Info tab — description, install/uninstall button
    RunPanel.tsx       # Run tab — inputs + live output
  PluginContract.ts    # Shared prop types (PluginRunPanelProps, PluginInfoPanelProps)
  PluginInfoLayout.tsx # Reusable wrapper for InfoPanel with install/uninstall logic
  pluginRegistry.ts    # Lazy-load map: name → () => import('./name')
  usePluginRun.ts      # Hook: run plugin, stream output lines, track running state
  NamespaceSelect.tsx  # Dropdown backed by live cluster namespaces from the store

Plugin metadata (name, description, category, homepage) lives in src/renderer/config/krewPlugins.json.

Key hooks and components

usePluginRun() — call run(pluginName, args) to invoke kubectl <plugin> <args>. Returns { lines, running, exitCode, run }. Lines are pre-split by newline with [stderr] prefix on stderr. Leading whitespace is preserved (important for YAML/tree output).

PluginInfoLayout — pass plugin, onInstall, onUninstall, and onOpen props. Handles loading state, error display, and the install/uninstall button rendering.

NamespaceSelect — reads namespaces from the Zustand store and renders a live dropdown. Falls back to a plain text input when no namespaces are loaded yet. Accepts includeAll prop to add an “all namespaces” option.

Adding a plugin

  1. Add an entry to src/renderer/config/krewPlugins.json.
  2. Create src/renderer/components/plugins/<name>/InfoPanel.tsx — use PluginInfoLayout as the wrapper.
  3. Create src/renderer/components/plugins/<name>/RunPanel.tsx — use usePluginRun to invoke and stream output.
  4. Register the loader in src/renderer/components/plugins/pluginRegistry.ts.

See stern or tree for simple examples; neat for Monaco editor output; outdated for custom parsed table output.


Copyright © 2026 Coding Protocols Private Limited. Distributed under the Apache 2.0 license.