fix(cli): improve bootstrap onboard UX and fix command references

Always pass --skip-ui and --accept-risk to openclaw onboard so the
wizard never prompts for TUI/Web UI selection — bootstrap manages the
web UI lifecycle itself. Add a post-onboard spinner to eliminate the
silent gap while config-set calls, gateway probing, and web runtime
startup run. Fix remediation messages to use `npx denchclaw`.
This commit is contained in:
kumarabhirup 2026-03-04 18:33:17 -08:00
parent 912e7711bb
commit 78e04e20b8
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
6 changed files with 138 additions and 18 deletions

View File

@ -290,11 +290,11 @@ if [[ "$SKIP_NPX_SMOKE" != true ]]; then
npx --yes "${PACKAGE_NAME}@${VERSION}" --version
verify_npx_command "$VERSION" "npx dench (via dench package)" \
npx --yes "${ALIAS_PACKAGE_NAME}@${VERSION}" --version
verify_npx_invocation "npx dench update --help" \
verify_npx_invocation "npx denchclaw update --help" \
npx --yes "${ALIAS_PACKAGE_NAME}@${VERSION}" update --help
verify_npx_invocation "npx dench start --help" \
verify_npx_invocation "npx denchclaw start --help" \
npx --yes "${ALIAS_PACKAGE_NAME}@${VERSION}" start --help
verify_npx_invocation "npx dench stop --help" \
verify_npx_invocation "npx denchclaw stop --help" \
npx --yes "${ALIAS_PACKAGE_NAME}@${VERSION}" stop --help
fi

View File

@ -1122,14 +1122,14 @@ function remediationForGatewayFailure(
if (normalized.includes("address already in use") || normalized.includes("eaddrinuse")) {
return `Port ${port} is busy. The bootstrap will auto-assign an available port, or you can explicitly specify one with \`--gateway-port <port>\`.`;
}
return `Run \`openclaw --profile ${profile} doctor --fix\` and retry \`denchclaw bootstrap --profile ${profile} --force-onboard\`.`;
return `Run \`openclaw --profile ${profile} doctor --fix\` and retry \`npx denchclaw bootstrap\`.`;
}
function remediationForWebUiFailure(port: number): string {
return [
`Web UI did not respond on ${port}.`,
`Run \`dench update --web-port ${port}\` to refresh the managed web runtime.`,
`If the port is stuck, run \`dench stop --web-port ${port}\` first.`,
`Run \`npx denchclaw update --web-port ${port}\` to refresh the managed web runtime.`,
`If the port is stuck, run \`npx denchclaw stop --web-port ${port}\` first.`,
].join(" ");
}
@ -1478,25 +1478,26 @@ export async function bootstrapCommand(
}
const bootstrapStartTime = Date.now();
track("cli_bootstrap_started", { version: VERSION });
if (!opts.json) {
const telemetryCfg = readTelemetryConfig();
if (!telemetryCfg.noticeShown) {
runtime.log(
theme.muted(
"DenchClaw collects anonymous telemetry to improve the product.\n" +
"Dench collects anonymous telemetry to improve the product.\n" +
"No personal data is ever collected. Disable anytime:\n" +
" denchclaw telemetry disable\n" +
" npx denchclaw telemetry disable\n" +
" DENCHCLAW_TELEMETRY_DISABLED=1\n" +
" DO_NOT_TRACK=1\n" +
"Learn more: https://github.com/openclaw/openclaw/blob/main/TELEMETRY.md\n",
"Learn more: https://github.com/DenchHQ/DenchClaw/blob/main/TELEMETRY.md\n",
),
);
markNoticeShown();
}
}
track("cli_bootstrap_started", { version: VERSION });
const installResult = await ensureOpenClawCliAvailable({
stateDir,
showProgress: !opts.json,
@ -1578,11 +1579,11 @@ export async function bootstrapCommand(
onboardArgv.push("--reset");
}
if (nonInteractive) {
onboardArgv.push("--non-interactive", "--accept-risk");
}
if (opts.noOpen) {
onboardArgv.push("--skip-ui");
onboardArgv.push("--non-interactive");
}
onboardArgv.push("--accept-risk", "--skip-ui");
if (nonInteractive) {
await runOpenClawOrThrow({
openclawCommand,
@ -1605,21 +1606,29 @@ export async function bootstrapCommand(
packageRoot,
});
const postOnboardSpinner = !opts.json ? spinner() : null;
postOnboardSpinner?.start("Finalizing configuration…");
// Ensure gateway.mode=local so the gateway never drifts to remote mode.
// Keep this post-onboard so we normalize any wizard defaults.
await ensureGatewayModeLocal(openclawCommand, profile);
postOnboardSpinner?.message("Configuring gateway port…");
// Persist the assigned port so all runtime clients (including web) resolve
// the same gateway target on subsequent requests.
await ensureGatewayPort(openclawCommand, profile, gatewayPort);
postOnboardSpinner?.message("Setting tools profile…");
// DenchClaw requires the full tool profile; onboarding defaults can drift to
// messaging-only, so enforce this on every bootstrap run.
await ensureToolsProfile(openclawCommand, profile);
postOnboardSpinner?.message("Configuring subagent defaults…");
await ensureSubagentDefaults(openclawCommand, profile);
postOnboardSpinner?.message("Probing gateway health…");
let gatewayProbe = await probeGateway(openclawCommand, profile, gatewayPort);
let gatewayAutoFix: GatewayAutoFixResult | undefined;
if (!gatewayProbe.ok) {
postOnboardSpinner?.message("Gateway unreachable, attempting auto-fix…");
gatewayAutoFix = await attemptGatewayAutoFix({
openclawCommand,
profile,
@ -1638,6 +1647,7 @@ export async function bootstrapCommand(
}
const gatewayUrl = `ws://127.0.0.1:${gatewayPort}`;
const preferredWebPort = parseOptionalPort(opts.webPort) ?? DEFAULT_WEB_APP_PORT;
postOnboardSpinner?.message(`Starting web runtime on port ${preferredWebPort}`);
const webRuntimeStatus = await ensureManagedWebRuntime({
stateDir,
packageRoot,
@ -1645,6 +1655,11 @@ export async function bootstrapCommand(
port: preferredWebPort,
gatewayPort,
});
postOnboardSpinner?.stop(
webRuntimeStatus.ready
? "Post-onboard setup complete."
: "Post-onboard setup complete (web runtime unhealthy).",
);
const webReachable = webRuntimeStatus.ready;
const webUrl = `http://localhost:${preferredWebPort}`;
const diagnostics = buildBootstrapDiagnostics({

View File

@ -2,6 +2,7 @@ import type { Command } from "commander";
import { getPrimaryCommand } from "../argv.js";
import type { ProgramContext } from "./context.js";
import { registerBootstrapCommand } from "./register.bootstrap.js";
import { registerRestartCommand } from "./register.restart.js";
import { registerStartCommand } from "./register.start.js";
import { registerStopCommand } from "./register.stop.js";
import { registerTelemetryCommand } from "./register.telemetry.js";
@ -48,6 +49,13 @@ const CORE_CLI_ENTRIES: CoreCliEntry[] = [
registerStartCommand(program);
},
},
{
name: "restart",
description: "Restart Dench managed web runtime",
register: ({ program }) => {
registerRestartCommand(program);
},
},
{
name: "telemetry",
description: "Manage anonymous telemetry",

View File

@ -0,0 +1,22 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { restartWebRuntimeCommand } from "../web-runtime-command.js";
export function registerRestartCommand(program: Command) {
program
.command("restart")
.description("Restart Dench managed web runtime (stop then start)")
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
.option("--web-port <port>", "Web runtime port override")
.option("--json", "Output summary as JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await restartWebRuntimeCommand({
profile: opts.profile as string | undefined,
webPort: opts.webPort as string | undefined,
json: Boolean(opts.json),
});
});
});
}

View File

@ -75,6 +75,7 @@ vi.mock("./web-runtime.js", () => ({
}));
import {
restartWebRuntimeCommand,
startWebRuntimeCommand,
stopWebRuntimeCommand,
updateWebRuntimeCommand,
@ -303,7 +304,7 @@ describe("startWebRuntimeCommand", () => {
});
const runtime = runtimeStub();
await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("dench update");
await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("npx denchclaw update");
expect(spawnMock).not.toHaveBeenCalled();
});
@ -331,3 +332,66 @@ describe("startWebRuntimeCommand", () => {
expect(summary.started).toBe(true);
});
});
describe("restartWebRuntimeCommand", () => {
beforeEach(() => {
webRuntimeMocks.ensureManagedWebRuntime.mockClear();
webRuntimeMocks.stopManagedWebRuntime.mockReset();
webRuntimeMocks.stopManagedWebRuntime.mockImplementation(
async () =>
({
port: 3100,
stoppedPids: [1234],
skippedForeignPids: [],
}) as { port: number; stoppedPids: number[]; skippedForeignPids: number[] },
);
webRuntimeMocks.startManagedWebRuntime.mockReset();
webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({
started: true,
pid: 7788,
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
}));
webRuntimeMocks.waitForWebRuntime.mockReset();
webRuntimeMocks.waitForWebRuntime.mockImplementation(
async () =>
({ ok: true, reason: "profiles payload shape is valid" }) as {
ok: boolean;
reason: string;
},
);
});
it("stops and restarts managed runtime (same stop+start lifecycle as start command)", async () => {
const runtime = runtimeStub();
const summary = await restartWebRuntimeCommand(
{
webPort: "3100",
},
runtime,
);
expect(webRuntimeMocks.stopManagedWebRuntime).toHaveBeenCalledWith({
stateDir: "/tmp/.openclaw-dench",
port: 3100,
includeLegacyStandalone: true,
});
expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({
stateDir: "/tmp/.openclaw-dench",
port: 3100,
gatewayPort: 18789,
});
expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled();
expect(summary.started).toBe(true);
});
it("outputs restart heading instead of start (distinct user-facing label)", async () => {
const runtime = runtimeStub();
await restartWebRuntimeCommand({}, runtime);
const logCalls = (runtime.log as ReturnType<typeof vi.fn>).mock.calls.map(
([msg]: [string]) => msg,
);
expect(logCalls.some((msg) => typeof msg === "string" && msg.includes("restart"))).toBe(true);
expect(logCalls.some((msg) => typeof msg === "string" && /\bstart\b/.test(msg) && !msg.includes("restart"))).toBe(false);
});
});

View File

@ -171,7 +171,7 @@ async function runOpenClawUpdateWithProgress(openclawCommand: string): Promise<v
throw new Error(
detail
? `OpenClaw update failed.\n${detail}`
: "OpenClaw update failed. Fix this before running `dench update` again.",
: "OpenClaw update failed. Fix this before running `npx denchclaw update` again.",
);
}
@ -399,9 +399,20 @@ export async function stopWebRuntimeCommand(
return summary;
}
export type RestartWebRuntimeOptions = StartWebRuntimeOptions;
export type RestartWebRuntimeSummary = StartWebRuntimeSummary;
export async function restartWebRuntimeCommand(
opts: RestartWebRuntimeOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<RestartWebRuntimeSummary> {
return startWebRuntimeCommand(opts, runtime, "restart");
}
export async function startWebRuntimeCommand(
opts: StartWebRuntimeOptions,
runtime: RuntimeEnv = defaultRuntime,
label: "start" | "restart" = "start",
): Promise<StartWebRuntimeSummary> {
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
const profile = appliedProfile.effectiveProfile;
@ -436,7 +447,7 @@ export async function startWebRuntimeCommand(
throw new Error(
[
`Managed web runtime is missing at ${runtimeServerPath}.`,
"Run `dench update` (or `dench bootstrap`) to install/update the web runtime first.",
"Run `npx denchclaw update` (or `npx denchclaw`) to install/update the web runtime first.",
].join(" "),
);
}
@ -457,7 +468,7 @@ export async function startWebRuntimeCommand(
}
runtime.log("");
runtime.log(theme.heading("Dench web start"));
runtime.log(theme.heading(`Dench web ${label}`));
runtime.log(`Profile: ${profile}`);
runtime.log(`Web port: ${selectedPort}`);
runtime.log(`Restarted managed web runtime: ${summary.started ? "yes" : "no"}`);