Skip to content

Commit 3aa2b01

Browse files
committed
feat: improve wizard skip UX and minikube image loading
1 parent 32bc88f commit 3aa2b01

6 files changed

Lines changed: 175 additions & 33 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ dist
33
.env
44
.work
55
data/runs.json
6+
data/.encryption-key
67
*.log
78
!tests/fixtures/*.log
89
docker-compose.override.yml
@@ -12,4 +13,4 @@ logs
1213
.research
1314
task_plan.md
1415
findings.md
15-
progress.md
16+
progress.md

scripts/kubernetes/build-app-image.sh

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,31 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
55
IMAGE_TAG="${1:-gooseherd/app:dev}"
66
DOCKERFILE_PATH="${KUBERNETES_APP_DOCKERFILE:-${ROOT_DIR}/kubernetes/app.Dockerfile}"
77
MINIKUBE_PROFILE="${MINIKUBE_PROFILE:-minikube}"
8-
MINIKUBE_BUILD_IN_NODE="${MINIKUBE_BUILD_IN_NODE:-0}"
98
DOCKER_CONFIG="${DOCKER_CONFIG:-/tmp/gooseherd-docker-config}"
109

1110
mkdir -p "${DOCKER_CONFIG}"
1211
export DOCKER_CONFIG
1312

14-
if [[ "${MINIKUBE_BUILD_IN_NODE}" == "1" ]]; then
15-
echo "[image] building ${IMAGE_TAG} directly in ${MINIKUBE_PROFILE} docker daemon"
16-
eval "$(minikube -p "${MINIKUBE_PROFILE}" docker-env)"
17-
# The local minikube app image consistently completes with the legacy builder,
18-
# while BuildKit occasionally stalls near the export path on this host.
19-
DOCKER_BUILDKIT=0 docker build -f "${DOCKERFILE_PATH}" -t "${IMAGE_TAG}" "${ROOT_DIR}"
13+
host_image_id() {
14+
docker image inspect --format '{{.Id}}' "${IMAGE_TAG}"
15+
}
16+
17+
node_image_id() {
18+
docker exec "${MINIKUBE_PROFILE}" docker image inspect --format '{{.Id}}' "${IMAGE_TAG}" 2>/dev/null || true
19+
}
20+
21+
# The local host app image consistently completes with the legacy builder,
22+
# while BuildKit occasionally stalls near the export path on this host.
23+
echo "[image] building ${IMAGE_TAG} on the host docker daemon"
24+
DOCKER_BUILDKIT=0 docker build -f "${DOCKERFILE_PATH}" -t "${IMAGE_TAG}" "${ROOT_DIR}"
25+
26+
HOST_IMAGE_ID="$(host_image_id)"
27+
NODE_IMAGE_ID="$(node_image_id)"
28+
if [[ -n "${NODE_IMAGE_ID}" && "${NODE_IMAGE_ID}" == "${HOST_IMAGE_ID}" ]]; then
29+
echo "[image] ${IMAGE_TAG} already present in ${MINIKUBE_PROFILE}"
2030
else
21-
# The local host app image consistently completes with the legacy builder,
22-
# while BuildKit occasionally stalls near the export path on this host.
23-
DOCKER_BUILDKIT=0 docker build -f "${DOCKERFILE_PATH}" -t "${IMAGE_TAG}" "${ROOT_DIR}"
24-
minikube image load "${IMAGE_TAG}"
31+
echo "[image] streaming ${IMAGE_TAG} into ${MINIKUBE_PROFILE} docker daemon"
32+
docker save "${IMAGE_TAG}" | docker exec -i "${MINIKUBE_PROFILE}" docker load
2533
fi
2634

2735
printf 'Loaded app image into minikube: %s\n' "${IMAGE_TAG}"

scripts/kubernetes/build-runner-image.sh

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ set -euo pipefail
44
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
55
IMAGE_TAG="${1:-gooseherd/k8s-runner:dev}"
66
MINIKUBE_PROFILE="${MINIKUBE_PROFILE:-minikube}"
7-
MINIKUBE_BUILD_IN_NODE="${MINIKUBE_BUILD_IN_NODE:-0}"
87
DOCKER_CONFIG="${DOCKER_CONFIG:-/tmp/gooseherd-docker-config}"
98

109
mkdir -p "${DOCKER_CONFIG}"
@@ -18,21 +17,16 @@ node_image_id() {
1817
docker exec "${MINIKUBE_PROFILE}" docker image inspect --format '{{.Id}}' "${IMAGE_TAG}" 2>/dev/null || true
1918
}
2019

21-
if [[ "${MINIKUBE_BUILD_IN_NODE}" == "1" ]]; then
22-
echo "[image] building ${IMAGE_TAG} on the host docker daemon"
23-
docker build -f "${ROOT_DIR}/kubernetes/runner.Dockerfile" -t "${IMAGE_TAG}" "${ROOT_DIR}"
20+
echo "[image] building ${IMAGE_TAG} on the host docker daemon"
21+
docker build -f "${ROOT_DIR}/kubernetes/runner.Dockerfile" -t "${IMAGE_TAG}" "${ROOT_DIR}"
2422

25-
HOST_IMAGE_ID="$(host_image_id)"
26-
NODE_IMAGE_ID="$(node_image_id)"
27-
if [[ -n "${NODE_IMAGE_ID}" && "${NODE_IMAGE_ID}" == "${HOST_IMAGE_ID}" ]]; then
28-
echo "[image] ${IMAGE_TAG} already present in ${MINIKUBE_PROFILE}"
29-
else
30-
echo "[image] streaming ${IMAGE_TAG} into ${MINIKUBE_PROFILE} docker daemon"
31-
docker save "${IMAGE_TAG}" | docker exec -i "${MINIKUBE_PROFILE}" docker load
32-
fi
23+
HOST_IMAGE_ID="$(host_image_id)"
24+
NODE_IMAGE_ID="$(node_image_id)"
25+
if [[ -n "${NODE_IMAGE_ID}" && "${NODE_IMAGE_ID}" == "${HOST_IMAGE_ID}" ]]; then
26+
echo "[image] ${IMAGE_TAG} already present in ${MINIKUBE_PROFILE}"
3327
else
34-
docker build -f "${ROOT_DIR}/kubernetes/runner.Dockerfile" -t "${IMAGE_TAG}" "${ROOT_DIR}"
35-
minikube image load "${IMAGE_TAG}"
28+
echo "[image] streaming ${IMAGE_TAG} into ${MINIKUBE_PROFILE} docker daemon"
29+
docker save "${IMAGE_TAG}" | docker exec -i "${MINIKUBE_PROFILE}" docker load
3630
fi
3731

3832
printf 'Loaded runner image into minikube: %s\n' "${IMAGE_TAG}"

src/dashboard/wizard-html.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export function wizardHtml(appName: string, reconfig = false): string {
102102
.btn-secondary:hover { background: #334155; }
103103
.btn-skip { background: transparent; color: #64748b; border: 1px solid #22314f; }
104104
.btn-skip:hover { background: #1e293b; }
105+
.btn-skip.ready { background: rgba(37, 99, 235, 0.2); color: #dbeafe; border-color: #3b82f6; box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.25); }
106+
.btn-skip.ready:hover { background: rgba(37, 99, 235, 0.32); }
105107
.btn-validate { background: #0f766e; color: #fff; flex: none; width: auto; padding: 10px 18px; }
106108
.btn-validate:hover { background: #115e59; }
107109
.error { color: #ef4444; font-size: 13px; margin-bottom: 12px; min-height: 18px; }
@@ -205,7 +207,7 @@ export function wizardHtml(appName: string, reconfig = false): string {
205207
</div>
206208
207209
<div class="btn-row">
208-
<button class="btn btn-skip" onclick="goStep(2)">Skip</button>
210+
<button class="btn btn-skip" id="gh-skip-btn" onclick="goStep(2)">Skip</button>
209211
<button class="btn btn-validate" id="gh-validate-btn" onclick="validateGithub()">Validate</button>
210212
<button class="btn btn-primary" onclick="saveGithub()">Save & Continue</button>
211213
</div>
@@ -234,7 +236,7 @@ export function wizardHtml(appName: string, reconfig = false): string {
234236
235237
<div class="btn-row">
236238
<button class="btn btn-secondary" onclick="goStep(1)">Back</button>
237-
<button class="btn btn-skip" onclick="goStep(3)">Skip</button>
239+
<button class="btn btn-skip" id="llm-skip-btn" onclick="goStep(3)">Skip</button>
238240
<button class="btn btn-validate" id="llm-validate-btn" onclick="validateLlm()">Validate</button>
239241
<button class="btn btn-primary" onclick="saveLlm()">Save & Continue</button>
240242
</div>
@@ -288,7 +290,7 @@ export function wizardHtml(appName: string, reconfig = false): string {
288290
289291
<div class="btn-row">
290292
<button class="btn btn-secondary" onclick="goStep(2)">Back</button>
291-
<button class="btn btn-skip" onclick="goStep(4)">Skip</button>
293+
<button class="btn btn-skip" id="slack-skip-btn" onclick="goStep(4)">Skip</button>
292294
<button class="btn btn-validate" id="slack-validate-btn" onclick="validateSlack()">Validate</button>
293295
<button class="btn btn-primary" onclick="saveSlack()">Save & Continue</button>
294296
</div>
@@ -310,6 +312,11 @@ export function wizardHtml(appName: string, reconfig = false): string {
310312
<script>
311313
let currentStep = 0;
312314
const state = { password: false, github: false, llm: false, slack: false };
315+
const prefillState = {
316+
github: { token: false, appId: false, installationId: false, privateKey: false },
317+
llm: { apiKey: false },
318+
slack: { botToken: false, appToken: false }
319+
};
313320
314321
function goStep(n) {
315322
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
@@ -320,13 +327,62 @@ function goStep(n) {
320327
if (i === n) d.classList.add('active');
321328
});
322329
currentStep = n;
330+
syncSkipButtons();
323331
if (n === 4) renderReview();
324332
}
325333
326334
function toggleGhMode() {
327335
const mode = document.querySelector('input[name="gh-mode"]:checked').value;
328336
document.getElementById('gh-pat-fields').classList.toggle('hidden', mode !== 'pat');
329337
document.getElementById('gh-app-fields').classList.toggle('hidden', mode !== 'app');
338+
syncSkipButtons();
339+
}
340+
341+
function hasValue(id) {
342+
const el = document.getElementById(id);
343+
return !!el && typeof el.value === 'string' && el.value.trim().length > 0;
344+
}
345+
346+
function hasPrefillValue(field) {
347+
return !!field && !!field.source && field.source !== 'none';
348+
}
349+
350+
function hasGithubRequiredFields() {
351+
const mode = document.querySelector('input[name="gh-mode"]:checked')?.value;
352+
if (mode === 'app') {
353+
return hasValue('gh-app-id') && hasValue('gh-install-id') && hasValue('gh-key');
354+
}
355+
return hasValue('gh-token');
356+
}
357+
358+
function hasLlmRequiredFields() {
359+
return hasValue('llm-key');
360+
}
361+
362+
function hasSlackRequiredFields() {
363+
return hasValue('slack-bot-token') && hasValue('slack-app-token');
364+
}
365+
366+
function hasGithubReadyForSkip() {
367+
const mode = document.querySelector('input[name="gh-mode"]:checked')?.value;
368+
if (mode === 'app') {
369+
return hasGithubRequiredFields() || (prefillState.github.appId && prefillState.github.installationId && prefillState.github.privateKey);
370+
}
371+
return hasGithubRequiredFields() || prefillState.github.token;
372+
}
373+
374+
function hasLlmReadyForSkip() {
375+
return hasLlmRequiredFields() || prefillState.llm.apiKey;
376+
}
377+
378+
function hasSlackReadyForSkip() {
379+
return hasSlackRequiredFields() || (prefillState.slack.botToken && prefillState.slack.appToken);
380+
}
381+
382+
function syncSkipButtons() {
383+
document.getElementById('gh-skip-btn')?.classList.toggle('ready', hasGithubReadyForSkip() || state.github);
384+
document.getElementById('llm-skip-btn')?.classList.toggle('ready', hasLlmReadyForSkip() || state.llm);
385+
document.getElementById('slack-skip-btn')?.classList.toggle('ready', hasSlackReadyForSkip() || state.slack);
330386
}
331387
332388
function sourceLabel(source) {
@@ -366,6 +422,14 @@ function renderPrefillSummary(containerId, rows) {
366422
function applySetupPrefill(prefill) {
367423
if (!prefill) return;
368424
425+
prefillState.github.token = hasPrefillValue(prefill.github?.token);
426+
prefillState.github.appId = hasPrefillValue(prefill.github?.appId);
427+
prefillState.github.installationId = hasPrefillValue(prefill.github?.installationId);
428+
prefillState.github.privateKey = hasPrefillValue(prefill.github?.privateKey);
429+
prefillState.llm.apiKey = hasPrefillValue(prefill.llm?.apiKey);
430+
prefillState.slack.botToken = hasPrefillValue(prefill.slack?.botToken);
431+
prefillState.slack.appToken = hasPrefillValue(prefill.slack?.appToken);
432+
369433
setGitHubAuthMode(prefill.github?.authMode);
370434
setInputValue('gh-owner', prefill.github?.defaultOwner);
371435
setInputValue('gh-app-id', prefill.github?.appId);
@@ -401,6 +465,8 @@ function applySetupPrefill(prefill) {
401465
{ label: 'Client secret', source: prefill.slack?.clientSecret?.source },
402466
{ label: 'Auth redirect URI', source: prefill.slack?.authRedirectUri?.source, value: prefill.slack?.authRedirectUri?.value },
403467
]);
468+
469+
syncSkipButtons();
404470
}
405471
406472
async function post(url, body) {
@@ -483,6 +549,7 @@ async function saveGithub() {
483549
return;
484550
}
485551
state.github = true;
552+
syncSkipButtons();
486553
goStep(2);
487554
}
488555
@@ -530,6 +597,7 @@ async function saveLlm() {
530597
return;
531598
}
532599
state.llm = true;
600+
syncSkipButtons();
533601
goStep(3); // → Slack step
534602
}
535603
@@ -582,6 +650,7 @@ async function saveSlack() {
582650
return;
583651
}
584652
state.slack = true;
653+
syncSkipButtons();
585654
goStep(4);
586655
}
587656
@@ -617,6 +686,11 @@ async function finishSetup() {
617686
}
618687
619688
// Check initial status
689+
['gh-token', 'gh-owner', 'gh-app-id', 'gh-install-id', 'gh-key', 'llm-key', 'llm-model', 'slack-bot-token', 'slack-app-token', 'slack-signing-secret', 'slack-command', 'slack-client-id', 'slack-client-secret', 'slack-auth-redirect-uri']
690+
.forEach(id => document.getElementById(id)?.addEventListener('input', syncSkipButtons));
691+
document.querySelectorAll('input[name="gh-mode"]').forEach(el => el.addEventListener('change', syncSkipButtons));
692+
syncSkipButtons();
693+
620694
fetch('/api/setup/status', { credentials: 'same-origin' })
621695
.then(r => r.json())
622696
.then(data => {
@@ -625,6 +699,7 @@ fetch('/api/setup/status', { credentials: 'same-origin' })
625699
if (data.hasLlm) state.llm = true;
626700
if (data.hasSlack) state.slack = true;
627701
applySetupPrefill(data.prefill);
702+
syncSkipButtons();
628703
${reconfig ? "// In reconfig mode, start from step 1 (skip password if already set)\n if (state.password) goStep(1);" : ""}
629704
})
630705
.catch(() => {});

tests/kubernetes-local-scripts.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,18 @@ for (const relativePath of [
3131
});
3232
}
3333

34-
test("build-app-image.sh builds directly in the minikube daemon when requested", async () => {
34+
test("build-app-image.sh streams the host build into minikube when needed", async () => {
3535
const contents = await readFile(path.join(rootDir, "scripts/kubernetes/build-app-image.sh"), "utf8");
3636

37-
assert.match(contents, /minikube -p "\$\{MINIKUBE_PROFILE\}" docker-env/);
38-
assert.match(contents, /\[image\] building .* directly in \$\{MINIKUBE_PROFILE\} docker daemon/);
37+
assert.match(contents, /\[image\] building .* on the host docker daemon/);
38+
assert.match(contents, /docker image inspect --format '\{\{\.Id\}\}' "\$\{IMAGE_TAG\}"/);
39+
assert.match(contents, /docker exec "\$\{MINIKUBE_PROFILE\}" docker image inspect --format '\{\{\.Id\}\}' "\$\{IMAGE_TAG\}"/);
40+
assert.match(contents, /\[image\] \$\{IMAGE_TAG\} already present in \$\{MINIKUBE_PROFILE\}/);
41+
assert.match(contents, /docker save "\$\{IMAGE_TAG\}" \| docker exec -i "\$\{MINIKUBE_PROFILE\}" docker load/);
3942
assert.doesNotMatch(contents, /docker save -o/);
4043
assert.doesNotMatch(contents, /docker load -i/);
44+
assert.doesNotMatch(contents, /minikube image load "\$\{IMAGE_TAG\}"/);
45+
assert.doesNotMatch(contents, /docker-env/);
4146
});
4247

4348
test("build-runner-image.sh keeps the host build and loads it into minikube", async () => {
@@ -49,7 +54,7 @@ test("build-runner-image.sh keeps the host build and loads it into minikube", as
4954
assert.match(contents, /\[image\] \$\{IMAGE_TAG\} already present in \$\{MINIKUBE_PROFILE\}/);
5055
assert.match(contents, /docker save "\$\{IMAGE_TAG\}" \| docker exec -i "\$\{MINIKUBE_PROFILE\}" docker load/);
5156
assert.doesNotMatch(contents, /docker save -o/);
52-
assert.doesNotMatch(contents, /minikube -p "\$\{MINIKUBE_PROFILE\}" image load "\$\{IMAGE_TAG\}"/);
57+
assert.doesNotMatch(contents, /minikube image load "\$\{IMAGE_TAG\}"/);
5358
assert.doesNotMatch(contents, /docker-env/);
5459
});
5560

tests/phase13.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import assert from "node:assert/strict";
66
import { describe, test, mock } from "node:test";
7+
import { createServer } from "node:http";
78
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
89
import os from "node:os";
910
import path from "node:path";
@@ -228,6 +229,38 @@ describe("setup wizard helpers", () => {
228229
assert.match(html, /applySetupPrefill\(data\.prefill\)/);
229230
});
230231

232+
test("wizardHtml promotes Skip buttons when the current step has its required fields", () => {
233+
const html = wizardHtml("Gooseherd");
234+
assert.match(html, /id="gh-skip-btn"/);
235+
assert.match(html, /id="llm-skip-btn"/);
236+
assert.match(html, /id="slack-skip-btn"/);
237+
assert.match(html, /function hasGithubRequiredFields\(\)/);
238+
assert.match(html, /function hasLlmRequiredFields\(\)/);
239+
assert.match(html, /function hasSlackRequiredFields\(\)/);
240+
assert.match(html, /function hasGithubReadyForSkip\(\)/);
241+
assert.match(html, /function hasLlmReadyForSkip\(\)/);
242+
assert.match(html, /function hasSlackReadyForSkip\(\)/);
243+
assert.match(html, /function syncSkipButtons\(\)/);
244+
assert.match(html, /const prefillState = \{/);
245+
assert.match(html, /token:\s*false/);
246+
assert.match(html, /apiKey:\s*false/);
247+
assert.match(html, /botToken:\s*false/);
248+
assert.match(html, /appToken:\s*false/);
249+
assert.match(html, /document\.getElementById\('gh-skip-btn'\)\?\.classList\.toggle\('ready', hasGithubReadyForSkip\(\) \|\| state\.github\)/);
250+
assert.match(html, /document\.getElementById\('llm-skip-btn'\)\?\.classList\.toggle\('ready', hasLlmReadyForSkip\(\) \|\| state\.llm\)/);
251+
assert.match(html, /document\.getElementById\('slack-skip-btn'\)\?\.classList\.toggle\('ready', hasSlackReadyForSkip\(\) \|\| state\.slack\)/);
252+
assert.match(html, /prefillState\.github\.token = hasPrefillValue\(prefill\.github\?\.token\)/);
253+
assert.match(html, /prefillState\.llm\.apiKey = hasPrefillValue\(prefill\.llm\?\.apiKey\)/);
254+
assert.match(html, /prefillState\.slack\.botToken = hasPrefillValue\(prefill\.slack\?\.botToken\)/);
255+
assert.match(html, /prefillState\.slack\.appToken = hasPrefillValue\(prefill\.slack\?\.appToken\)/);
256+
});
257+
258+
test("wizardHtml gives ready Skip buttons a distinct visual treatment", () => {
259+
const html = wizardHtml("Gooseherd");
260+
assert.match(html, /\.btn-skip\.ready \{ background: rgba\(37, 99, 235, 0\.2\); color: #dbeafe; border-color: #3b82f6; box-shadow: inset 0 0 0 1px rgba\(96, 165, 250, 0\.25\); \}/);
261+
assert.match(html, /\.btn-skip\.ready:hover \{ background: rgba\(37, 99, 235, 0\.32\); \}/);
262+
});
263+
231264
// ── validateGithubToken ──
232265

233266
test("validateGithubToken accepts ghp_ prefix", () => {
@@ -652,6 +685,32 @@ describe("ObserverDaemon tokenGetter", () => {
652685

653686
await testDb.cleanup();
654687
});
688+
689+
test("ObserverDaemon does not bind observerWebhookPort when dashboard server is enabled", async (t) => {
690+
const mod = await import("../src/observer/daemon.js");
691+
const ObserverDaemon = mod.ObserverDaemon;
692+
const testDb = await createTestDb();
693+
694+
const occupied = createServer();
695+
await new Promise<void>((resolve) => occupied.listen(0, "127.0.0.1", () => resolve()));
696+
const occupiedPort = (occupied.address() as { port: number }).port;
697+
698+
const mockConfig = makeMinimalConfig({
699+
dashboardEnabled: true,
700+
observerGithubWebhookSecret: "github-secret",
701+
observerWebhookPort: occupiedPort,
702+
}) as any;
703+
mockConfig.dataDir = os.tmpdir();
704+
705+
const daemon = new ObserverDaemon(mockConfig, { onRunTerminal: () => {} } as any, undefined, undefined, undefined, testDb.db);
706+
t.after(async () => {
707+
occupied.close();
708+
await daemon.stop();
709+
await testDb.cleanup();
710+
});
711+
712+
await daemon.start();
713+
});
655714
});
656715

657716
// ══════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)