You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/09/14 13:43:39 UTC

(juneau) branch juneau-release-manager updated: WORK-R0009: wire Setup templates, rm-setup.js, AppConfiguration/RootRest, e2e, and Setup/Home tests.

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch juneau-release-manager
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/juneau-release-manager by this push:
     new 7396284389 WORK-R0009: wire Setup templates, rm-setup.js, AppConfiguration/RootRest, e2e, and Setup/Home tests.
7396284389 is described below

commit 739628438968b96ae086a2c45bcb13210e38cfe5
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Sep 14 09:43:33 2026 -0400

    WORK-R0009: wire Setup templates, rm-setup.js, AppConfiguration/RootRest, e2e, and Setup/Home tests.
---
 e2e/playwright.config.ts                           |   2 +-
 e2e/tests/home.spec.ts                             |  18 +-
 e2e/tests/nav.spec.ts                              |  16 +-
 e2e/tests/smoke.spec.ts                            |  26 +--
 .../org/apache/juneau/releng/AppConfiguration.java |  14 ++
 .../java/org/apache/juneau/releng/RootRest.java    |   5 +-
 src/main/resources/static/css/chrome.css           | 101 ++++++++---
 src/main/resources/static/index.html               |   4 +-
 src/main/resources/static/js/rm-setup.js           | 139 +++++++++++++++
 src/main/resources/templates/admin.ftlh            |   1 -
 src/main/resources/templates/base.ftlh             |   6 +-
 src/main/resources/templates/credentials.ftlh      |  57 ------
 src/main/resources/templates/home.ftlh             |  42 -----
 .../resources/templates/{admin.ftlh => setup.ftlh} |  31 ++--
 ...treamTest.java => ReleaseManagerThemeTest.java} |  36 ++--
 .../juneau/releng/rest/CredentialRestTest.java     |  23 ++-
 .../HomeRestTest.java}                             |  36 ++--
 .../apache/juneau/releng/rest/SetupRestTest.java   | 131 ++++++++++++++
 .../juneau/releng/setup/SetupProbeServiceTest.java | 193 +++++++++++++++++++++
 .../releng/util/ProcessRunnerStreamTest.java       |  22 +++
 start.sh                                           |   3 +-
 21 files changed, 683 insertions(+), 223 deletions(-)

diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
index ca9f9212a6..56c304b934 100644
--- a/e2e/playwright.config.ts
+++ b/e2e/playwright.config.ts
@@ -68,7 +68,7 @@ export default defineConfig({
   webServer: {
     command:
       `mvn -q spring-boot:run -Dspring-boot.run.arguments=--server.port=${TEST_PORT}`,
-    url: BASE_URL + '/rest/home',
+    url: BASE_URL + '/rest/setup',
     cwd: APP_ROOT,
     timeout: 120_000,
     reuseExistingServer: !process.env.CI,
diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts
index c247a7ae3d..11c399eecd 100644
--- a/e2e/tests/home.spec.ts
+++ b/e2e/tests/home.spec.ts
@@ -17,15 +17,19 @@
 
 import { test, expect } from '@playwright/test';
 
-test.describe('Home page', () => {
-  test('boots and renders the workflow heading', async ({ page }) => {
-    const response = await page.goto('/rest/home');
+test.describe('Setup page', () => {
+  test('boots probes and details; home 302s here', async ({ page }) => {
+    const home = await page.goto('/rest/home');
+    expect(home?.status()).toBe(200);
+    await expect(page).toHaveURL(/\/rest\/setup/);
+
+    const response = await page.goto('/rest/setup');
     expect(response?.status()).toBe(200);
 
-    await expect(page.getByRole('heading', { name: 'Workflow' })).toBeVisible();
-    await expect(page.getByRole('heading', { name: 'Prerequisites' })).toBeVisible();
+    await expect(page.getByRole('heading', { name: 'Probes' })).toBeVisible();
+    await expect(page.getByRole('heading', { name: 'Details' })).toBeVisible();
+    await expect(page.getByRole('heading', { name: 'Workflow' })).toHaveCount(0);
 
-    // Home nav tab is marked active on its own page.
-    await expect(page.getByRole('link', { name: 'Home' })).toHaveClass(/active/);
+    await expect(page.getByRole('link', { name: 'Setup' })).toHaveClass(/active/);
   });
 });
diff --git a/e2e/tests/nav.spec.ts b/e2e/tests/nav.spec.ts
index 85fb52a447..9cfd8da45e 100644
--- a/e2e/tests/nav.spec.ts
+++ b/e2e/tests/nav.spec.ts
@@ -22,9 +22,8 @@ import { test, expect, type Page } from '@playwright/test';
  * which route each tab should land on and what landmark proves the right page rendered. Kept independent of the
  * control-row/ribbon markup (which is in flux) — these are page-level landmarks only.
  */
-const NAV_TABS: { name: string; path: string; heading: string | RegExp }[] = [
-  { name: 'Home', path: '/rest/home', heading: 'Workflow' },
-  { name: 'Credentials', path: '/rest/credentials', heading: /apache|github|gpg/i },
+const NAV_TABS: { name: string; path: string; heading: string | RegExp | null }[] = [
+  { name: 'Setup', path: '/rest/setup', heading: 'Probes' },
   { name: 'Releases', path: '/rest/releases', heading: 'All Releases' },
   // New Release has no single fixed heading — its Input-vs-Execution subtab starts on whichever one matches
   // current run state (state-dependent), so it's asserted separately below via the always-present subtab bar.
@@ -32,8 +31,6 @@ const NAV_TABS: { name: string; path: string; heading: string | RegExp }[] = [
   { name: 'Admin', path: '/rest/admin', heading: 'Admin' },
 ];
 
-// The Admin page composes its own tab bar reusing the "Releases"/"Credentials" labels, so every top-nav lookup
-// is scoped to the persistent header nav (base.ftlh's `nav.jc-nav`) to avoid ambiguity there.
 function topNav(page: Page) {
   return page.locator('nav.jc-nav');
 }
@@ -41,7 +38,7 @@ function topNav(page: Page) {
 test.describe('Top navigation', () => {
   for (const tab of NAV_TABS) {
     test(`"${tab.name}" tab is present and navigates to a working page`, async ({ page }) => {
-      await page.goto('/rest/home');
+      await page.goto('/rest/setup');
 
       const link = topNav(page).getByRole('link', { name: tab.name, exact: true });
       await expect(link).toBeVisible();
@@ -51,12 +48,9 @@ test.describe('Top navigation', () => {
       expect(response?.status()).toBe(200);
 
       if (tab.name === 'New Release') {
-        // Input/Execution subtabs (role="tab") are always present regardless of whether a run is active.
         await expect(page.getByRole('tab', { name: 'Input' })).toBeVisible();
         await expect(page.getByRole('tab', { name: 'Execution' })).toBeVisible();
       } else if (tab.heading instanceof RegExp) {
-        // Credentials has no single fixed heading (data-driven content), so assert on a resilient text
-        // landmark instead of a specific DOM structure.
         await expect(page.getByText(tab.heading).first()).toBeVisible();
       } else if (tab.heading) {
         await expect(page.getByRole('heading', { name: tab.heading })).toBeVisible();
@@ -64,12 +58,14 @@ test.describe('Top navigation', () => {
     });
   }
 
-  test('all five nav tabs are present on every page', async ({ page }) => {
+  test('all four nav tabs are present on every page', async ({ page }) => {
     for (const startTab of NAV_TABS) {
       await page.goto(startTab.path);
       for (const tab of NAV_TABS) {
         await expect(topNav(page).getByRole('link', { name: tab.name, exact: true })).toBeVisible();
       }
+      await expect(topNav(page).getByRole('link', { name: 'Home', exact: true })).toHaveCount(0);
+      await expect(topNav(page).getByRole('link', { name: 'Credentials', exact: true })).toHaveCount(0);
     }
   });
 });
diff --git a/e2e/tests/smoke.spec.ts b/e2e/tests/smoke.spec.ts
index 35ff558730..df23dfa9c2 100644
--- a/e2e/tests/smoke.spec.ts
+++ b/e2e/tests/smoke.spec.ts
@@ -46,24 +46,24 @@ test.describe('Smoke: primary content renders without console errors', () => {
     expect(response?.status()).toBe(200);
 
     await expect(page.getByRole('heading', { name: 'Admin' })).toBeVisible();
-    // Composed tab bar (PageTable) — leaf tabs for the two dogfooded views. Scoped to .jc-tab-bar (the
-    // PageTable-owned tablist) since the top nav also has same-named "Releases"/"Credentials" links.
-    const adminTabBar = page.locator('.jc-tab-bar');
-    await expect(adminTabBar.getByText('Releases', { exact: true })).toBeVisible();
-    await expect(adminTabBar.getByText('Credentials', { exact: true })).toBeVisible();
+    const adminNav = page.locator('.juneau-page-nav');
+    await expect(adminNav.getByText('Releases', { exact: true })).toBeVisible();
+    await expect(adminNav.getByText('Credentials', { exact: true })).toHaveCount(0);
 
     expect(errors, `console errors on /rest/admin: ${errors.join('; ')}`).toEqual([]);
   });
 
-  test('Credentials page renders its primary content', async ({ page }) => {
-    const errors = collectConsoleErrors(page);
+  test('Credentials human page is 404', async ({ page }) => {
     const response = await page.goto('/rest/credentials');
-    expect(response?.status()).toBe(200);
-
-    // One card per managed credential (Apache LDAP / GPG / GitHub) — assert on the label text rather than a
-    // specific count, since the set of managed credentials could grow.
-    await expect(page.getByText(/GitHub token/i)).toBeVisible();
+    expect(response?.status()).toBe(404);
+  });
 
-    expect(errors, `console errors on /rest/credentials: ${errors.join('; ')}`).toEqual([]);
+  test('Setup page renders probes', async ({ page }) => {
+    const errors = collectConsoleErrors(page);
+    const response = await page.goto('/rest/setup');
+    expect(response?.status()).toBe(200);
+    await expect(page.getByRole('heading', { name: 'Probes' })).toBeVisible();
+    await expect(page.getByRole('button', { name: 'GitHub token' })).toBeVisible();
+    expect(errors, `console errors on /rest/setup: ${errors.join('; ')}`).toEqual([]);
   });
 });
diff --git a/src/main/java/org/apache/juneau/releng/AppConfiguration.java b/src/main/java/org/apache/juneau/releng/AppConfiguration.java
index e3639b614a..d0b1766a86 100644
--- a/src/main/java/org/apache/juneau/releng/AppConfiguration.java
+++ b/src/main/java/org/apache/juneau/releng/AppConfiguration.java
@@ -54,6 +54,8 @@ import org.apache.juneau.releng.release.ReleaseListService;
 import org.apache.juneau.releng.rest.CredentialRest;
 import org.apache.juneau.releng.rest.HomeRest;
 import org.apache.juneau.releng.rest.MilestoneRest;
+import org.apache.juneau.releng.rest.SetupRest;
+import org.apache.juneau.releng.setup.SetupProbeService;
 import org.apache.juneau.releng.rest.ReleaseRest;
 import org.apache.juneau.releng.rest.ReleaseRunRest;
 import org.apache.juneau.releng.util.ProcessRunner;
@@ -194,6 +196,18 @@ public class AppConfiguration {
 		return new HomeRest();
 	}
 
+	@Bean
+	public SetupProbeService setupProbeService(ProcessRunner runner, CredentialService credentials,
+			@Value("${rm.repo.dir}") String repoDir) {
+		return new SetupProbeService(runner, credentials, Path.of(repoDir),
+				Path.of(System.getProperty("user.home"), ".m2", "settings.xml"));
+	}
+
+	@Bean
+	public SetupRest setupRest(SetupProbeService setup) {
+		return new SetupRest(setup);
+	}
+
 	@Bean
 	public ReleaseRest releaseRest(ReleaseListService svc) {
 		return new ReleaseRest(svc);
diff --git a/src/main/java/org/apache/juneau/releng/RootRest.java b/src/main/java/org/apache/juneau/releng/RootRest.java
index 625bd25d28..d6dbcc1eda 100644
--- a/src/main/java/org/apache/juneau/releng/RootRest.java
+++ b/src/main/java/org/apache/juneau/releng/RootRest.java
@@ -25,9 +25,10 @@ import org.apache.juneau.releng.rest.MilestoneRest;
 import org.apache.juneau.releng.rest.CredentialRest;
 import org.apache.juneau.releng.rest.ReleaseRunRest;
 import org.apache.juneau.releng.rest.AdminRest;
+import org.apache.juneau.releng.rest.SetupRest;
 
-@Rest(path = "/rest/*", title = "Apache Juneau · Release Manager", children = { HomeRest.class, ReleaseRest.class,
-		MilestoneRest.class, CredentialRest.class, ReleaseRunRest.class, AdminRest.class })
+@Rest(path = "/rest/*", title = "Apache Juneau · Release Manager", children = { SetupRest.class, HomeRest.class,
+		ReleaseRest.class, MilestoneRest.class, CredentialRest.class, ReleaseRunRest.class, AdminRest.class })
 @SuppressWarnings({ "java:S110" // Inheritance depth is imposed by the Juneau REST servlet hierarchy.
 })
 public class RootRest extends BasicSpringRestServletGroup {
diff --git a/src/main/resources/static/css/chrome.css b/src/main/resources/static/css/chrome.css
index 8b28d2ff97..4baac3dff3 100644
--- a/src/main/resources/static/css/chrome.css
+++ b/src/main/resources/static/css/chrome.css
@@ -25,9 +25,9 @@
    /juneau-console/chrome.css (see base.ftlh's <head> link). This file holds
    only what's genuinely app-specific and has no console-ui equivalent:
    DataTables-chrome integration, credential cards, the release-detail list
-   grid, two app-only tag values, and Home-tab content styling. Every
+   grid, two app-only tag values, and Setup-tab probe styling. Every
    var(--jc-*) reference below resolves against the tokens the shipped chrome
-   response appends (Theme.OPEN, overridden by ReleaseManagerTheme).
+   response appends (Theme.LIGHT_RED, overridden by ReleaseManagerTheme).
    ========================================================================== */
 
 /* ==========================================================================
@@ -287,41 +287,102 @@ table.dataTable.rm-table thead > tr > th.dt-ordering-desc .dt-column-order:after
 }
 
 /* ==========================================================================
-   Home tab (welcome / instructions)
+   Setup tab (probes + details)
    ========================================================================== */
 
-.rm-home-steps,
-.rm-home-prereqs {
+.rm-setup {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 16px;
+  align-items: start;
+  width: 100%;
+  max-width: 1180px;
+  margin: 0 auto;
+}
+
+.rm-setup > .jc-card {
+  max-width: none;
   margin: 0;
-  padding: 4px 0 4px 20px;
+  min-width: 0;
+}
+
+.rm-probe-row {
+  display: flex;
+  flex-wrap: nowrap;
+  gap: 8px;
+  padding: 4px 0 8px;
+  overflow-x: auto;
+}
+
+.rm-probe-pill {
+  font-family: var(--jc-font);
   font-size: 0.8125rem;
-  color: var(--jc-text);
-  line-height: 1.6;
+  font-weight: 600;
+  border: 1px solid var(--jc-border);
+  border-radius: 999px;
+  padding: 4px 12px;
+  background: var(--jc-tag-neutral-bg);
+  color: var(--jc-tag-neutral-text);
+  cursor: pointer;
 }
 
-.rm-home-steps li,
-.rm-home-prereqs li {
-  margin-bottom: 8px;
+.rm-probe-pill-pass {
+  background: var(--jc-tag-green-bg);
+  color: var(--jc-tag-green-text);
+  border-color: var(--jc-tag-green-border);
 }
 
-.rm-home-steps li:last-child,
-.rm-home-prereqs li:last-child {
-  margin-bottom: 4px;
+.rm-probe-pill-fail {
+  background: var(--jc-tag-red-bg);
+  color: var(--jc-tag-red-text);
+  border-color: var(--jc-tag-red-border);
 }
 
-.rm-home-steps code,
-.rm-home-prereqs code,
-.rm-page-sub code {
+.rm-probe-pill-warn {
+  background: var(--jc-tag-amber-bg);
+  color: var(--jc-tag-amber-text);
+  border-color: var(--jc-tag-amber-border);
+}
+
+.rm-probe-pill-pending {
+  background: var(--jc-tag-neutral-bg);
+  color: var(--jc-tag-neutral-text);
+}
+
+.rm-probe-pill-selected {
+  box-shadow: 0 0 0 2px var(--jc-accent);
+  background: var(--jc-accent-wash);
+}
+
+.rm-probe-placeholder {
+  color: var(--jc-text-muted);
+  font-size: 0.875rem;
+  margin: 8px 0;
+}
+
+.rm-probe-copy,
+.rm-probe-install-out {
   font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
   font-size: 0.75rem;
   background: var(--jc-chrome-bg);
   border: 1px solid var(--jc-border);
-  border-radius: 3px;
-  padding: 1px 5px;
+  border-radius: var(--jc-radius);
+  padding: 8px 10px;
+  overflow-x: auto;
+  white-space: pre-wrap;
+}
+
+#rm-probe-copy-btn,
+#rm-probe-install-btn {
+  margin: 8px 8px 0 0;
+}
+
+@media (max-width: 720px) {
+  .rm-setup { grid-template-columns: 1fr; }
 }
 
 /* ==========================================================================
-   Credential cards (Credentials tab)
+   Credential cards (Setup Details write UI)
    ==========================================================================
 
    Not shown in the design mockup (which only covers the Releases tab), so
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
index afd0fc96f1..00c478cbe4 100644
--- a/src/main/resources/static/index.html
+++ b/src/main/resources/static/index.html
@@ -20,9 +20,9 @@
 <head>
     <meta charset="utf-8">
     <title>Apache Juneau Release Manager</title>
-    <script>location.replace('/rest/home');</script>
+    <script>location.replace('/rest/setup');</script>
 </head>
 <body>
-    <noscript>Redirecting to <a href="/rest/home">the Release Manager</a>&hellip;</noscript>
+    <noscript>Redirecting to <a href="/rest/setup">the Release Manager</a>&hellip;</noscript>
 </body>
 </html>
diff --git a/src/main/resources/static/js/rm-setup.js b/src/main/resources/static/js/rm-setup.js
new file mode 100644
index 0000000000..cd9ba551c2
--- /dev/null
+++ b/src/main/resources/static/js/rm-setup.js
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+(function () {
+    const row = document.getElementById('rm-probe-row');
+    const details = document.getElementById('rm-probe-details');
+    if (!row || !details) return;
+
+    let probes = [];
+    let selectedId = null;
+
+    function statusClass(status) {
+        if (status === 'pass') return 'rm-probe-pill-pass';
+        if (status === 'fail') return 'rm-probe-pill-fail';
+        if (status === 'warn') return 'rm-probe-pill-warn';
+        return 'rm-probe-pill-pending';
+    }
+
+    function escapeHtml(s) {
+        return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
+            '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
+        }[c]));
+    }
+
+    function renderPills() {
+        row.querySelectorAll('[data-probe-id]').forEach((el) => {
+            const p = probes.find((x) => x.id === el.getAttribute('data-probe-id'));
+            el.className = 'rm-probe-pill ' + statusClass(p ? p.status : 'pending');
+            if (el.getAttribute('data-probe-id') === selectedId)
+                el.classList.add('rm-probe-pill-selected');
+        });
+    }
+
+    function credentialForm(p) {
+        const name = p.credentialName;
+        let acct = '';
+        if (name !== 'github') {
+            const ph = name === 'apache' ? 'Apache availid' : 'GPG key ID';
+            acct = '<input type="text" class="acct" placeholder="' + ph + '">';
+        }
+        const sph = name === 'apache' ? 'password' : name === 'gpg' ? 'passphrase' : 'token';
+        return '<div class="cred" data-name="' + escapeHtml(name) + '">'
+            + '<div class="row">'
+            + acct
+            + '<input type="password" class="secret" placeholder="' + sph + '">'
+            + '<button type="button" class="jc-btn jc-btn-primary" onclick="rmSet(\'' + name + '\', this)">Save</button>'
+            + '<button type="button" class="jc-btn jc-btn-outline" onclick="rmValidate(\'' + name + '\', this)">Validate</button>'
+            + '</div>'
+            + '<div class="msg"></div></div>';
+    }
+
+    function renderDetails() {
+        const p = probes.find((x) => x.id === selectedId);
+        if (!p) {
+            details.innerHTML = '<p class="rm-probe-placeholder">Select a probe.</p>';
+            return;
+        }
+        const bits = [];
+        bits.push('<h3>' + escapeHtml(p.label) + '</h3>');
+        bits.push('<dl class="rm-detail-list">');
+        bits.push('<dt>Status</dt><dd>' + escapeHtml(p.status || 'pending') + '</dd>');
+        bits.push('<dt>Message</dt><dd>' + escapeHtml(p.message || '') + '</dd>');
+        bits.push('<dt>Next step</dt><dd>' + escapeHtml(p.nextStep || '') + '</dd>');
+        bits.push('</dl>');
+        if (p.copyCommand) {
+            bits.push('<pre class="rm-probe-copy" id="rm-probe-copy-text">' + escapeHtml(p.copyCommand) + '</pre>');
+            bits.push('<button type="button" class="jc-btn jc-btn-outline jc-btn-sm" id="rm-probe-copy-btn">Copy command</button>');
+        }
+        if (p.installable) {
+            bits.push('<button type="button" class="jc-btn jc-btn-primary" id="rm-probe-install-btn">Install</button>');
+            bits.push('<pre class="rm-probe-install-out" id="rm-probe-install-out" hidden></pre>');
+        }
+        if (p.kind === 'credential')
+            bits.push(credentialForm(p));
+        details.innerHTML = bits.join('');
+        const copyBtn = document.getElementById('rm-probe-copy-btn');
+        if (copyBtn)
+            copyBtn.addEventListener('click', () => navigator.clipboard.writeText(p.copyCommand));
+        const installBtn = document.getElementById('rm-probe-install-btn');
+        if (installBtn)
+            installBtn.addEventListener('click', () => install(p.id));
+    }
+
+    async function install(id) {
+        const out = document.getElementById('rm-probe-install-out');
+        const btn = document.getElementById('rm-probe-install-btn');
+        if (btn) btn.disabled = true;
+        if (out) {
+            out.hidden = false;
+            out.textContent = 'Installing…';
+        }
+        try {
+            const r = await fetch('/rest/setup/install/' + encodeURIComponent(id), { method: 'POST' });
+            const body = await r.json();
+            if (out)
+                out.textContent = body.output || (r.ok ? 'Done' : 'Install failed');
+            await loadData();
+            renderDetails();
+        } catch (e) {
+            if (out)
+                out.textContent = String(e);
+        } finally {
+            if (btn) btn.disabled = false;
+        }
+    }
+
+    async function loadData() {
+        const r = await fetch('/rest/setup/data');
+        const body = await r.json();
+        probes = body.probes || [];
+        renderPills();
+        if (selectedId)
+            renderDetails();
+    }
+
+    row.addEventListener('click', (ev) => {
+        const btn = ev.target.closest('[data-probe-id]');
+        if (!btn) return;
+        selectedId = btn.getAttribute('data-probe-id');
+        renderPills();
+        renderDetails();
+    });
+
+    loadData();
+})();
diff --git a/src/main/resources/templates/admin.ftlh b/src/main/resources/templates/admin.ftlh
index 9e275821c9..10df6bc012 100644
--- a/src/main/resources/templates/admin.ftlh
+++ b/src/main/resources/templates/admin.ftlh
@@ -28,7 +28,6 @@
             </div>
             <div class="juneau-page-nav-children">
                 <a class="juneau-page-nav-child" href="/rest/admin/releases"<#if selectedChild == 'releases'> aria-current="page"</#if>>Releases</a>
-                <a class="juneau-page-nav-child" href="/rest/admin/credentials"<#if selectedChild == 'credentials'> aria-current="page"</#if>>Credentials</a>
             </div>
         </nav>
         <div id="${slotId}"></div>
diff --git a/src/main/resources/templates/base.ftlh b/src/main/resources/templates/base.ftlh
index ec50d97ec1..816a64a480 100644
--- a/src/main/resources/templates/base.ftlh
+++ b/src/main/resources/templates/base.ftlh
@@ -74,8 +74,7 @@
          assigns before including this layout — no hardcoded per-page string
          compares here. -->
     <nav class="jc-nav">
-        <a class="jc-nav-tab<#if activeTab?? && activeTab == 'home'> active</#if>" href="/rest/home">Home</a>
-        <a class="jc-nav-tab<#if activeTab?? && activeTab == 'credentials'> active</#if>" href="/rest/credentials">Credentials</a>
+        <a class="jc-nav-tab<#if activeTab?? && activeTab == 'setup'> active</#if>" href="/rest/setup">Setup</a>
         <a class="jc-nav-tab<#if activeTab?? && activeTab == 'releases'> active</#if>" href="/rest/releases">Releases</a>
         <a class="jc-nav-tab<#if activeTab?? && activeTab == 'new'> active</#if>" href="/rest/runs">New Release</a>
         <a class="jc-nav-tab<#if activeTab?? && activeTab == 'admin'> active</#if>" href="/rest/admin">Admin</a>
@@ -114,8 +113,9 @@
     <script src="${tableSlotJsUrl}"></script>
     </#if>
     </#if>
-    <#if activeTab?? && activeTab == 'credentials'>
+    <#if activeTab?? && activeTab == 'setup'>
     <script src="/js/credentials.js"></script>
+    <script src="/js/rm-setup.js"></script>
     </#if>
     <#if activeTab?? && activeTab == 'new'>
     <link rel="stylesheet" href="/css/new-release.css">
diff --git a/src/main/resources/templates/credentials.ftlh b/src/main/resources/templates/credentials.ftlh
deleted file mode 100644
index 3cd21d906e..0000000000
--- a/src/main/resources/templates/credentials.ftlh
+++ /dev/null
@@ -1,57 +0,0 @@
-<#--
-  Licensed to the Apache Software Foundation (ASF) under one or more
-  contributor license agreements.  See the NOTICE file distributed with
-  this work for additional information regarding copyright ownership.
-  The ASF licenses this file to You under the Apache License, Version 2.0
-  (the "License"); you may not use this file except in compliance with
-  the License.  You may obtain a copy of the License at
-
-      http://www.apache.org/licenses/LICENSE-2.0
-
-  Unless required by applicable law or agreed to in writing, software
-  distributed under the License is distributed on an "AS IS" BASIS,
-  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  See the License for the specific language governing permissions and
-  limitations under the License.
--->
-
-<#assign activeTab = "credentials">
-<#macro content>
-    <div class="jc-card">
-        <#list credentials as c>
-        <div class="cred" data-name="${c.name}">
-            <#--
-              Four states, four treatments. "Stored but never tested" used to share the red `invalid` class, which
-              claimed the credential was bad when the app had no idea and sent the user off to re-enter a password
-              that was probably fine. It must not read as a pass either -- hence amber rather than green.
-
-              lastValid is a boxed Boolean whose null means "never validated", so the untested branch tests for
-              absence (!c.lastValid??) and must come before the branches that read the value. Validation results
-              are held in memory only, so a restart correctly returns every credential here.
-            -->
-            <#if !c.present>
-                <#assign pillClass = 'unset'><#assign pillLabel = 'Not set'>
-            <#elseif !c.lastValid??>
-                <#assign pillClass = 'untested'><#assign pillLabel = 'Stored — not tested'>
-            <#elseif c.lastValid>
-                <#assign pillClass = 'valid'><#assign pillLabel = 'Valid'>
-            <#else>
-                <#assign pillClass = 'invalid'><#assign pillLabel = 'Invalid'>
-            </#if>
-            <h3>${c.label}
-                <span class="pill ${pillClass}">${pillLabel}</span>
-            </h3>
-            <div class="row">
-                <#if c.name != 'github'>
-                    <input type="text" class="acct" placeholder="<#if c.name=='apache'>Apache availid<#else>GPG key ID</#if>">
-                </#if>
-                <input type="password" class="secret" placeholder="<#if c.name=='apache'>password<#elseif c.name=='gpg'>passphrase<#else>token</#if>">
-                <button class="jc-btn jc-btn-primary" onclick="rmSet('${c.name}', this)">Save</button>
-                <button class="jc-btn jc-btn-outline" onclick="rmValidate('${c.name}', this)">Validate</button>
-            </div>
-            <div class="msg">${c.lastMessage!''}</div>
-        </div>
-        </#list>
-    </div>
-</#macro>
-<#include "base.ftlh">
diff --git a/src/main/resources/templates/home.ftlh b/src/main/resources/templates/home.ftlh
deleted file mode 100644
index 1ab226d10d..0000000000
--- a/src/main/resources/templates/home.ftlh
+++ /dev/null
@@ -1,42 +0,0 @@
-<#--
-  Licensed to the Apache Software Foundation (ASF) under one or more
-  contributor license agreements.  See the NOTICE file distributed with
-  this work for additional information regarding copyright ownership.
-  The ASF licenses this file to You under the Apache License, Version 2.0
-  (the "License"); you may not use this file except in compliance with
-  the License.  You may obtain a copy of the License at
-
-      http://www.apache.org/licenses/LICENSE-2.0
-
-  Unless required by applicable law or agreed to in writing, software
-  distributed under the License is distributed on an "AS IS" BASIS,
-  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  See the License for the specific language governing permissions and
-  limitations under the License.
--->
-
-<#assign activeTab = "home">
-<#macro content>
-    <div class="jc-card">
-        <div class="jc-card-header">
-            <h2>Workflow</h2>
-        </div>
-        <ol class="rm-home-steps">
-            <li><strong><a href="/rest/credentials">Credentials</a></strong> &mdash; configure and validate your Apache LDAP password, GPG signing key, and GitHub token first. Everything else depends on these being set up correctly.</li>
-            <li><strong><a href="/rest/releases">Releases</a></strong> &mdash; review past and existing releases and their current status.</li>
-            <li><strong><a href="/rest/releases#new">New Release</a></strong> &mdash; start and drive a new release through its steps.</li>
-        </ol>
-    </div>
-
-    <div class="jc-card">
-        <div class="jc-card-header">
-            <h2>Prerequisites</h2>
-        </div>
-        <ul class="rm-home-prereqs">
-            <li>A local checkout of <code>apache/juneau</code>.</li>
-            <li><code>mvn</code>, <code>git</code>, <code>gh</code>, and <code>gpg</code> available on your <code>PATH</code>.</li>
-            <li>Valid <code>apache.releases.https</code> credentials in <code>~/.m2/settings.xml</code>.</li>
-        </ul>
-    </div>
-</#macro>
-<#include "base.ftlh">
diff --git a/src/main/resources/templates/admin.ftlh b/src/main/resources/templates/setup.ftlh
similarity index 51%
copy from src/main/resources/templates/admin.ftlh
copy to src/main/resources/templates/setup.ftlh
index 9e275821c9..566547a11c 100644
--- a/src/main/resources/templates/admin.ftlh
+++ b/src/main/resources/templates/setup.ftlh
@@ -15,24 +15,27 @@
   limitations under the License.
 -->
 
-<#assign activeTab = "admin">
+<#assign activeTab = "setup">
 <#macro content>
-    <div class="jc-card">
-        <div class="jc-card-header">
-            <h2>Admin</h2>
+    <div class="rm-setup">
+        <div class="jc-card rm-setup-probes">
+            <div class="jc-card-header">
+                <h2>Probes</h2>
+            </div>
+            <div class="rm-probe-row" id="rm-probe-row">
+                <#list inventory as p>
+                <button type="button" class="rm-probe-pill rm-probe-pill-pending" data-probe-id="${p.id}" data-kind="${p.kind}">${p.label}</button>
+                </#list>
+            </div>
         </div>
-
-        <nav class="juneau-page-nav" aria-label="Admin">
-            <div class="juneau-page-nav-sections">
-                <a class="juneau-page-nav-section" href="/rest/admin/releases" aria-current="page">Admin</a>
+        <div class="jc-card rm-setup-details">
+            <div class="jc-card-header">
+                <h2>Details</h2>
             </div>
-            <div class="juneau-page-nav-children">
-                <a class="juneau-page-nav-child" href="/rest/admin/releases"<#if selectedChild == 'releases'> aria-current="page"</#if>>Releases</a>
-                <a class="juneau-page-nav-child" href="/rest/admin/credentials"<#if selectedChild == 'credentials'> aria-current="page"</#if>>Credentials</a>
+            <div id="rm-probe-details">
+                <p class="rm-probe-placeholder">Select a probe.</p>
             </div>
-        </nav>
-        <div id="${slotId}"></div>
-        <script type="application/json" id="rm-table-slot">${tableSlotJson?no_esc}</script>
+        </div>
     </div>
 </#macro>
 <#include "base.ftlh">
diff --git a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java b/src/test/java/org/apache/juneau/releng/ReleaseManagerThemeTest.java
similarity index 50%
copy from src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
copy to src/test/java/org/apache/juneau/releng/ReleaseManagerThemeTest.java
index 43d6cc01ed..9fbe446f23 100644
--- a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
+++ b/src/test/java/org/apache/juneau/releng/ReleaseManagerThemeTest.java
@@ -15,32 +15,26 @@
  * limitations under the License.
  */
 
-package org.apache.juneau.releng.util;
+package org.apache.juneau.releng;
 
 import static org.junit.jupiter.api.Assertions.*;
-import java.util.ArrayList;
-import java.util.List;
-import org.junit.jupiter.api.Test;
 
-class ProcessRunnerStreamTest {
+import org.apache.juneau.rest.server.console.Theme;
+import org.junit.jupiter.api.Test;
 
-	@Test
-	void a01_streamsEachLineToSinkAndReturnsExitCode() {
-		var runner = new ProcessRunner.Default();
-		var lines = new ArrayList<String>();
-		var res = runner.run(List.of("sh", "-c", "printf 'a\\nb\\nc\\n'"), null, null, lines::add);
-		assertEquals(0, res.exitCode());
-		assertEquals(List.of("a", "b", "c"), lines);
-		assertTrue(res.output().contains("a"));
-	}
+class ReleaseManagerThemeTest {
 
 	@Test
-	void a02_nonZeroExitStillStreamsAndReports() {
-		var runner = new ProcessRunner.Default();
-		var lines = new ArrayList<String>();
-		var res = runner.run(List.of("sh", "-c", "echo hi; exit 7"), null, null, lines::add);
-		assertEquals(7, res.exitCode());
-		assertFalse(res.ok());
-		assertEquals(List.of("hi"), lines);
+	void a01_derivesFromLightRedAndKeepsDangerTagTriad() {
+		var tokens = ReleaseManagerTheme.INSTANCE.getTokens();
+		assertEquals("release-manager", ReleaseManagerTheme.INSTANCE.getName());
+		assertEquals(Theme.LIGHT_RED.getTokens().get("--jc-btn-primary"), tokens.get("--jc-btn-primary"));
+		assertEquals(Theme.LIGHT_RED.getTokens().get("--jc-chrome-bg"), tokens.get("--jc-chrome-bg"));
+		assertNotEquals("#BF2600", tokens.get("--jc-chrome-bg"));
+		assertNotEquals("#1589EE", tokens.get("--jc-accent"));
+		assertEquals("#fdeceb", tokens.get("--jc-tag-red-bg"));
+		assertEquals("#c23934", tokens.get("--jc-tag-red-text"));
+		assertEquals("#f3c6c2", tokens.get("--jc-tag-red-border"));
+		assertNotEquals(Theme.OPEN.getTokens().get("--jc-tag-red-text"), tokens.get("--jc-tag-red-text"));
 	}
 }
diff --git a/src/test/java/org/apache/juneau/releng/rest/CredentialRestTest.java b/src/test/java/org/apache/juneau/releng/rest/CredentialRestTest.java
index 2e4890c635..0ff70e5d62 100644
--- a/src/test/java/org/apache/juneau/releng/rest/CredentialRestTest.java
+++ b/src/test/java/org/apache/juneau/releng/rest/CredentialRestTest.java
@@ -34,7 +34,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 /**
- * Credentials envelope JSON lives on this resource without {@code ViewsMixin}; the human page stays the secret-store UI.
+ * Human Credentials page is 404; write/status APIs stay.
  */
 class CredentialRestTest {
 
@@ -56,25 +56,30 @@ class CredentialRestTest {
 	}
 
 	@Test
-	void a01_viewEnvelopeIsSlotMetaWithoutCsrf() throws Exception {
+	void a01_viewEnvelopeIsGone() throws Exception {
 		try (var client = client()) {
 			try (var resp = client.request("GET", "/view").header("Accept", "application/json").run()) {
-				assertEquals(200, resp.getStatusCode());
-				ReleaseRestTest.assertSlotEnvelope(resp.getBodyAsString(), "credentials");
+				assertEquals(404, resp.getStatusCode());
 			}
 		}
 	}
 
 	@Test
-	void a02_pageRemainsTheSecretStoreUi() throws Exception {
+	void a02_humanPageIs404() throws Exception {
 		try (var client = client()) {
 			try (var resp = client.request("GET", "/").run()) {
+				assertEquals(404, resp.getStatusCode());
+			}
+		}
+	}
+
+	@Test
+	void a03_statusRemains() throws Exception {
+		try (var client = client()) {
+			try (var resp = client.request("GET", "/status").header("Accept", "application/json").run()) {
 				assertEquals(200, resp.getStatusCode());
 				var body = resp.getBodyAsString();
-				assertTrue(body.contains("/js/credentials.js"), body);
-				assertFalse(body.contains("class=\"juneau-page-nav\""), body);
-				assertFalse(body.contains("id=\"rm-table-slot\""), body);
-				assertFalse(body.contains("juneau-view:credentials"), body);
+				assertTrue(body.contains("apache") || body.contains("GitHub") || body.contains("github"), body);
 			}
 		}
 	}
diff --git a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java b/src/test/java/org/apache/juneau/releng/rest/HomeRestTest.java
similarity index 53%
copy from src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
copy to src/test/java/org/apache/juneau/releng/rest/HomeRestTest.java
index 43d6cc01ed..e8237ce2c3 100644
--- a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
+++ b/src/test/java/org/apache/juneau/releng/rest/HomeRestTest.java
@@ -15,32 +15,30 @@
  * limitations under the License.
  */
 
-package org.apache.juneau.releng.util;
+package org.apache.juneau.releng.rest;
 
 import static org.junit.jupiter.api.Assertions.*;
-import java.util.ArrayList;
-import java.util.List;
+
+import org.apache.juneau.commons.inject.StackOverlay;
+import org.apache.juneau.rest.mock.MockRestClient;
 import org.junit.jupiter.api.Test;
 
-class ProcessRunnerStreamTest {
+class HomeRestTest {
 
-	@Test
-	void a01_streamsEachLineToSinkAndReturnsExitCode() {
-		var runner = new ProcessRunner.Default();
-		var lines = new ArrayList<String>();
-		var res = runner.run(List.of("sh", "-c", "printf 'a\\nb\\nc\\n'"), null, null, lines::add);
-		assertEquals(0, res.exitCode());
-		assertEquals(List.of("a", "b", "c"), lines);
-		assertTrue(res.output().contains("a"));
+	@SuppressWarnings({
+		"resource" // Caller owns and closes the returned MockRestClient (via try-with-resources).
+	})
+	private static MockRestClient client() {
+		return MockRestClient.builder(new HomeRest()).overridingBeanStore(new StackOverlay()).build();
 	}
 
 	@Test
-	void a02_nonZeroExitStillStreamsAndReports() {
-		var runner = new ProcessRunner.Default();
-		var lines = new ArrayList<String>();
-		var res = runner.run(List.of("sh", "-c", "echo hi; exit 7"), null, null, lines::add);
-		assertEquals(7, res.exitCode());
-		assertFalse(res.ok());
-		assertEquals(List.of("hi"), lines);
+	void a01_homeRedirectsToSetup() throws Exception {
+		try (var client = client()) {
+			try (var resp = client.request("GET", "/").run()) {
+				assertEquals(302, resp.getStatusCode());
+				assertEquals("/rest/setup", resp.header("Location").getValue());
+			}
+		}
 	}
 }
diff --git a/src/test/java/org/apache/juneau/releng/rest/SetupRestTest.java b/src/test/java/org/apache/juneau/releng/rest/SetupRestTest.java
new file mode 100644
index 0000000000..d5d708ad0e
--- /dev/null
+++ b/src/test/java/org/apache/juneau/releng/rest/SetupRestTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.juneau.releng.rest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.juneau.commons.inject.StackOverlay;
+import org.apache.juneau.commons.secret.InMemorySecretStore;
+import org.apache.juneau.commons.secret.SecretStore;
+import org.apache.juneau.commons.utils.IoUtils;
+import org.apache.juneau.releng.credential.AccountStore;
+import org.apache.juneau.releng.credential.CredentialService;
+import org.apache.juneau.releng.credential.CredentialSpec;
+import org.apache.juneau.releng.setup.SetupProbeService;
+import org.apache.juneau.releng.util.ProcessRunner;
+import org.apache.juneau.rest.mock.MockRestClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class SetupRestTest {
+
+	private SetupProbeService service;
+
+	@BeforeEach
+	void setUp(@TempDir Path tmp) {
+		var stores = new EnumMap<CredentialSpec, SecretStore>(CredentialSpec.class);
+		for (var spec : CredentialSpec.values())
+			stores.put(spec, new InMemorySecretStore());
+		var creds = new CredentialService(stores, new EnumMap<>(CredentialSpec.class), new AccountStore(tmp));
+		service = new SetupProbeService(new FakeRunner(), creds, tmp.resolve("missing-repo"), tmp.resolve("missing-settings.xml"));
+	}
+
+	@SuppressWarnings({
+		"resource" // Caller owns and closes the returned MockRestClient (via try-with-resources).
+	})
+	private MockRestClient client() {
+		return MockRestClient.builder(new SetupRest(service)).overridingBeanStore(new StackOverlay()).build();
+	}
+
+	@Test
+	void a01_pageRendersProbePillsWithoutSldsOrSsc() throws Exception {
+		try (var client = client()) {
+			try (var resp = client.request("GET", "/").run()) {
+				assertEquals(200, resp.getStatusCode());
+				var body = resp.getBodyAsString();
+				assertTrue(body.contains("Probes"), body);
+				assertTrue(body.contains("Details"), body);
+				assertTrue(body.contains("data-probe-id=\"checkout\""), body);
+				assertTrue(body.contains("data-probe-id=\"github\""), body);
+				assertTrue(body.contains("/js/rm-setup.js"), body);
+				assertFalse(body.contains("slds-"), body);
+				assertFalse(body.contains("ssc-"), body);
+				assertFalse(body.contains("Workflow"), body);
+			}
+		}
+	}
+
+	@Test
+	void a02_dataReturnsJsonProbes() throws Exception {
+		try (var client = client()) {
+			try (var resp = client.request("GET", "/data").header("Accept", "application/json").run()) {
+				assertEquals(200, resp.getStatusCode());
+				var body = resp.getBodyAsString();
+				assertTrue(body.contains("\"id\":\"checkout\""), body);
+				assertTrue(body.contains("\"id\":\"gpg-key\""), body);
+			}
+		}
+	}
+
+	@Test
+	void a03_installUnknownIs404() throws Exception {
+		try (var client = client()) {
+			try (var resp = client.request("POST", "/install/checkout").run()) {
+				assertEquals(404, resp.getStatusCode());
+			}
+		}
+	}
+
+	@Test
+	void a04_staticAssetsContainNoSalesforceTokens() throws IOException {
+		for (var path : List.of("/static/js/rm-setup.js", "/static/css/chrome.css", "/templates/setup.ftlh")) {
+			try (var in = SetupRestTest.class.getResourceAsStream(path)) {
+				assertNotNull(in, path);
+				var text = new String(IoUtils.readBytes(in), StandardCharsets.UTF_8);
+				assertFalse(text.contains("slds-"), path + " " + text);
+				assertFalse(text.contains("ssc-"), path + " " + text);
+				assertFalse(text.toLowerCase().contains("salesforce"), path);
+			}
+		}
+	}
+
+	private static final class FakeRunner implements ProcessRunner {
+		@Override
+		public List<String> runLines(List<String> command) {
+			return List.of();
+		}
+
+		@Override
+		public String runText(List<String> command) {
+			return "";
+		}
+
+		@Override
+		public ProcResult run(List<String> command, String stdin, Map<String, String> env) {
+			return new ProcResult(1, "");
+		}
+	}
+}
diff --git a/src/test/java/org/apache/juneau/releng/setup/SetupProbeServiceTest.java b/src/test/java/org/apache/juneau/releng/setup/SetupProbeServiceTest.java
new file mode 100644
index 0000000000..2e7f3015f6
--- /dev/null
+++ b/src/test/java/org/apache/juneau/releng/setup/SetupProbeServiceTest.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.juneau.releng.setup;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import org.apache.juneau.commons.secret.InMemorySecretStore;
+import org.apache.juneau.commons.secret.SecretStore;
+import org.apache.juneau.http.response.NotFound;
+import org.apache.juneau.releng.credential.AccountStore;
+import org.apache.juneau.releng.credential.CredentialService;
+import org.apache.juneau.releng.credential.CredentialSpec;
+import org.apache.juneau.releng.util.ProcessRunner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class SetupProbeServiceTest {
+
+	private FakeRunner runner;
+	private SetupProbeService setup;
+	private Path repo;
+	private Path settings;
+
+	@BeforeEach
+	void setUp(@TempDir Path tmp) throws Exception {
+		repo = tmp.resolve("juneau");
+		Files.createDirectories(repo.resolve(".git"));
+		Files.writeString(repo.resolve("pom.xml"), "<project><artifactId>juneau</artifactId></project>");
+		settings = tmp.resolve("settings.xml");
+		Files.writeString(settings, """
+				<settings>
+				  <servers>
+				    <server>
+				      <id>apache.releases.https</id>
+				      <username>me</username>
+				      <password>x</password>
+				    </server>
+				  </servers>
+				</settings>
+				""");
+		runner = new FakeRunner();
+		runner.onPath.add("mvn");
+		runner.onPath.add("git");
+		runner.onPath.add("gh");
+		runner.onPath.add("gpg");
+		runner.onPath.add("brew");
+		var stores = new EnumMap<CredentialSpec, SecretStore>(CredentialSpec.class);
+		for (var spec : CredentialSpec.values())
+			stores.put(spec, new InMemorySecretStore());
+		var creds = new CredentialService(stores, new EnumMap<>(CredentialSpec.class), new AccountStore(tmp.resolve("state")));
+		setup = new SetupProbeService(runner, creds, repo, settings);
+	}
+
+	@Test
+	void a01_inventoryHasPrereqsThenCredentials() {
+		var ids = setup.inventory().stream().map(p -> p.id).toList();
+		assertEquals(List.of("checkout", "mvn", "git", "gh", "gpg", "settings-xml", "apache", "gpg-key", "github"), ids);
+		assertEquals("pending", setup.inventory().get(0).status);
+	}
+
+	@Test
+	void a02_dataEagerPassWhenToolsAndCheckoutPresent() {
+		var data = setup.data();
+		assertEquals("brew", data.packageManager);
+		assertEquals("pass", byId(data, "checkout").status);
+		assertEquals("pass", byId(data, "mvn").status);
+		assertEquals("pass", byId(data, "settings-xml").status);
+		assertEquals("fail", byId(data, "apache").status);
+		assertTrue(byId(data, "mvn").installable);
+		assertFalse(byId(data, "checkout").installable);
+		assertEquals("apache", byId(data, "apache").credentialName);
+		assertEquals("gpg", byId(data, "gpg-key").credentialName);
+	}
+
+	@Test
+	void a03_settingsFailIsMissingServerIdNotMereFile() throws Exception {
+		Files.writeString(settings, "<settings><servers></servers></settings>");
+		var p = byId(setup.data(), "settings-xml");
+		assertEquals("fail", p.status);
+		assertTrue(p.message.contains("apache.releases.https"), p.message);
+	}
+
+	@Test
+	void a04_preferBrewWhenAptAlsoPresent() {
+		runner.onPath.add("apt-get");
+		assertEquals("brew", setup.data().packageManager);
+	}
+
+	@Test
+	void a05_aptWhenNoBrew() {
+		runner.onPath.remove("brew");
+		runner.onPath.add("apt-get");
+		var data = setup.data();
+		assertEquals("apt", data.packageManager);
+		assertTrue(byId(data, "git").copyCommand.contains("apt-get"));
+	}
+
+	@Test
+	void a06_installBrewUsesTimeoutAndClosedStdinPath() {
+		var r = setup.install("mvn");
+		assertTrue(r.ok);
+		assertTrue(runner.commands.contains(List.of("brew", "install", "maven")), runner.commands.toString());
+		assertEquals(Duration.ofMinutes(10), runner.lastTimeout);
+	}
+
+	@Test
+	void a07_installAptIsNonInteractive() {
+		runner.onPath.remove("brew");
+		runner.onPath.add("apt-get");
+		setup.install("gpg");
+		assertTrue(runner.commands.contains(List.of("apt-get", "install", "-y", "gnupg")), runner.commands.toString());
+		assertEquals("noninteractive", runner.lastEnv.get("DEBIAN_FRONTEND"));
+	}
+
+	@Test
+	void a08_installUnknownProbeIs404() {
+		assertThrows(NotFound.class, () -> setup.install("checkout"));
+		assertThrows(NotFound.class, () -> setup.install("settings-xml"));
+		assertThrows(NotFound.class, () -> setup.install("nope"));
+	}
+
+	private static SetupProbeService.Probe byId(SetupProbeService.SetupData data, String id) {
+		return data.probes.stream().filter(p -> id.equals(p.id)).findFirst().orElseThrow();
+	}
+
+	private static final class FakeRunner implements ProcessRunner {
+		final Set<String> onPath = new HashSet<>();
+		final List<List<String>> commands = new ArrayList<>();
+		List<String> lastCommand = List.of();
+		Map<String, String> lastEnv = Map.of();
+		Duration lastTimeout;
+		ProcResult installResult = new ProcResult(0, "ok");
+
+		@Override
+		public List<String> runLines(List<String> command) {
+			return List.of();
+		}
+
+		@Override
+		public String runText(List<String> command) {
+			return "";
+		}
+
+		@Override
+		public ProcResult run(List<String> command, String stdin, Map<String, String> env) {
+			commands.add(List.copyOf(command));
+			lastCommand = command;
+			if (env != null && !command.isEmpty() && !"which".equals(command.get(0)))
+				lastEnv = env;
+			if (!command.isEmpty() && "which".equals(command.get(0)))
+				return new ProcResult(onPath.contains(command.get(1)) ? 0 : 1, "");
+			return installResult;
+		}
+
+		@Override
+		public ProcResult run(List<String> command, String stdin, Map<String, String> env, Duration timeout) {
+			lastTimeout = timeout;
+			return run(command, stdin, env);
+		}
+
+		@Override
+		public ProcResult run(List<String> command, String stdin, Map<String, String> env, Consumer<String> lineSink) {
+			return run(command, stdin, env);
+		}
+	}
+}
diff --git a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java b/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
index 43d6cc01ed..741a47f15a 100644
--- a/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
+++ b/src/test/java/org/apache/juneau/releng/util/ProcessRunnerStreamTest.java
@@ -43,4 +43,26 @@ class ProcessRunnerStreamTest {
 		assertFalse(res.ok());
 		assertEquals(List.of("hi"), lines);
 	}
+
+	@Test
+	void a03_timeoutKillsAHungProcess() {
+		var runner = new ProcessRunner.Default();
+		var start = System.nanoTime();
+		var res = runner.run(List.of("sleep", "30"), null, null, java.time.Duration.ofMillis(400));
+		var elapsedMs = (System.nanoTime() - start) / 1_000_000;
+		assertFalse(res.ok());
+		assertEquals(124, res.exitCode());
+		assertTrue(res.output().contains("Timed out"), res.output());
+		assertTrue(elapsedMs < 8_000, "timeout path hung: " + elapsedMs + "ms");
+	}
+
+	@Test
+	void a04_nullStdinClosesSoReadDoesNotHang() {
+		var runner = new ProcessRunner.Default();
+		var start = System.nanoTime();
+		var res = runner.run(List.of("sh", "-c", "read line; echo after"), null, null, java.time.Duration.ofSeconds(3));
+		var elapsedMs = (System.nanoTime() - start) / 1_000_000;
+		assertTrue(elapsedMs < 2_500, "stdin was left open: " + elapsedMs + "ms");
+		assertTrue(res.output().contains("after") || res.exitCode() != 0, res.output());
+	}
 }
diff --git a/start.sh b/start.sh
index 3fa3bb1144..44566fb1c5 100755
--- a/start.sh
+++ b/start.sh
@@ -26,9 +26,8 @@
 #
 # The Juneau REST resources are mounted under /rest/*, e.g.:
 #
-#   http://127.0.0.1:8790/rest/home
+#   http://127.0.0.1:8790/rest/setup
 #   http://127.0.0.1:8790/rest/releases
-#   http://127.0.0.1:8790/rest/credentials
 #
 # Bind address/port come from src/main/resources/application.properties.
 #