Files
openclaw/src/commands/onboard-helpers.ts
2026-04-05 15:57:47 +01:00

297 lines
9.7 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { inspect } from "node:util";
import { cancel, isCancel } from "@clack/prompts";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH } from "../config/config.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { resolveControlUiLinks } from "../gateway/control-ui-links.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { probeGateway } from "../gateway/probe.js";
import {
detectBrowserOpenSupport,
openUrl,
openUrlInBackground,
resolveBrowserOpenCommand,
} from "../infra/browser-open.js";
import { detectBinary } from "../infra/detect-binary.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { CONFIG_DIR, shortenHomeInString, shortenHomePath, sleep } from "../utils.js";
import { VERSION } from "../version.js";
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
export { detectBinary };
export { detectBrowserOpenSupport, openUrl, openUrlInBackground, resolveBrowserOpenCommand };
export { resolveControlUiLinks };
export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
if (isCancel(value)) {
cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled.");
runtime.exit(0);
throw new Error("unreachable");
}
return value;
}
export function summarizeExistingConfig(config: OpenClawConfig): string {
const rows: string[] = [];
const defaults = config.agents?.defaults;
if (defaults?.workspace) {
rows.push(shortenHomeInString(`workspace: ${defaults.workspace}`));
}
if (defaults?.model) {
const model = resolveAgentModelPrimaryValue(defaults.model);
if (model) {
rows.push(shortenHomeInString(`model: ${model}`));
}
}
if (config.gateway?.mode) {
rows.push(shortenHomeInString(`gateway.mode: ${config.gateway.mode}`));
}
if (typeof config.gateway?.port === "number") {
rows.push(shortenHomeInString(`gateway.port: ${config.gateway.port}`));
}
if (config.gateway?.bind) {
rows.push(shortenHomeInString(`gateway.bind: ${config.gateway.bind}`));
}
if (config.gateway?.remote?.url) {
rows.push(shortenHomeInString(`gateway.remote.url: ${config.gateway.remote.url}`));
}
if (config.skills?.install?.nodeManager) {
rows.push(shortenHomeInString(`skills.nodeManager: ${config.skills.install.nodeManager}`));
}
return rows.length ? rows.join("\n") : "No key settings detected.";
}
export function randomToken(): string {
return crypto.randomBytes(24).toString("hex");
}
export function normalizeGatewayTokenInput(value: unknown): string {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
// Reject the literal string "undefined" — a common bug when JS undefined
// gets coerced to a string via template literals or String(undefined).
if (trimmed === "undefined" || trimmed === "null") {
return "";
}
return trimmed;
}
export function validateGatewayPasswordInput(value: unknown): string | undefined {
if (typeof value !== "string") {
return "Required";
}
const trimmed = value.trim();
if (!trimmed) {
return "Required";
}
if (trimmed === "undefined" || trimmed === "null") {
return 'Cannot be the literal string "undefined" or "null"';
}
return undefined;
}
export function printWizardHeader(runtime: RuntimeEnv) {
const header = [
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
"██░▄▄▄░██░▄▄░██░▄▄▄██░▀██░██░▄▄▀██░████░▄▄▀██░███░██",
"██░███░██░▀▀░██░▄▄▄██░█░█░██░█████░████░▀▀░██░█░█░██",
"██░▀▀▀░██░█████░▀▀▀██░██▄░██░▀▀▄██░▀▀░█░██░██▄▀▄▀▄██",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 OPENCLAW 🦞 ",
" ",
].join("\n");
runtime.log(header);
}
export function applyWizardMetadata(
cfg: OpenClawConfig,
params: { command: string; mode: OnboardMode },
): OpenClawConfig {
const commit = process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim() || undefined;
return {
...cfg,
wizard: {
...cfg.wizard,
lastRunAt: new Date().toISOString(),
lastRunVersion: VERSION,
lastRunCommit: commit,
lastRunCommand: params.command,
lastRunMode: params.mode,
},
};
}
export function formatControlUiSshHint(params: {
port: number;
basePath?: string;
token?: string;
}): string {
const basePath = normalizeControlUiBasePath(params.basePath);
const uiPath = basePath ? `${basePath}/` : "/";
const localUrl = `http://localhost:${params.port}${uiPath}`;
const authedUrl = params.token
? `${localUrl}#token=${encodeURIComponent(params.token)}`
: undefined;
const sshTarget = resolveSshTargetHint();
return [
"No GUI detected. Open from your computer:",
`ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`,
"Then open:",
localUrl,
authedUrl,
"Docs:",
"https://docs.openclaw.ai/gateway/remote",
"https://docs.openclaw.ai/web/control-ui",
]
.filter(Boolean)
.join("\n");
}
function resolveSshTargetHint(): string {
const user = process.env.USER || process.env.LOGNAME || "user";
const conn = process.env.SSH_CONNECTION?.trim().split(/\s+/);
const host = conn?.[2] ?? "<host>";
return `${user}@${host}`;
}
export async function ensureWorkspaceAndSessions(
workspaceDir: string,
runtime: RuntimeEnv,
options?: { skipBootstrap?: boolean; agentId?: string },
) {
const ws = await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: !options?.skipBootstrap,
});
runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`);
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`);
}
export function resolveNodeManagerOptions(): Array<{
value: NodeManagerChoice;
label: string;
}> {
return [
{ value: "npm", label: "npm" },
{ value: "pnpm", label: "pnpm" },
{ value: "bun", label: "bun" },
];
}
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<void> {
if (!pathname) {
return;
}
try {
await fs.access(pathname);
} catch {
return;
}
try {
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
} catch {
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
}
}
export async function handleReset(scope: ResetScope, workspaceDir: string, runtime: RuntimeEnv) {
await moveToTrash(CONFIG_PATH, runtime);
if (scope === "config") {
return;
}
await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime);
await moveToTrash(resolveSessionTranscriptsDirForAgent(), runtime);
if (scope === "full") {
await moveToTrash(workspaceDir, runtime);
}
}
export async function probeGatewayReachable(params: {
url: string;
token?: string;
password?: string;
timeoutMs?: number;
}): Promise<{ ok: boolean; detail?: string }> {
const url = params.url.trim();
const timeoutMs = params.timeoutMs ?? 1500;
try {
const probe = await probeGateway({
url,
timeoutMs,
auth: {
token: params.token,
password: params.password,
},
detailLevel: "none",
});
return probe.ok ? { ok: true } : { ok: false, detail: probe.error ?? undefined };
} catch (err) {
return { ok: false, detail: summarizeError(err) };
}
}
export async function waitForGatewayReachable(params: {
url: string;
token?: string;
password?: string;
/** Total time to wait before giving up. */
deadlineMs?: number;
/** Per-probe timeout (each probe makes a full gateway health request). */
probeTimeoutMs?: number;
/** Delay between probes. */
pollMs?: number;
}): Promise<{ ok: boolean; detail?: string }> {
const deadlineMs = params.deadlineMs ?? 15_000;
const pollMs = params.pollMs ?? 400;
const probeTimeoutMs = params.probeTimeoutMs ?? 1500;
const startedAt = Date.now();
let lastDetail: string | undefined;
while (Date.now() - startedAt < deadlineMs) {
const probe = await probeGatewayReachable({
url: params.url,
token: params.token,
password: params.password,
timeoutMs: probeTimeoutMs,
});
if (probe.ok) {
return probe;
}
lastDetail = probe.detail;
await sleep(pollMs);
}
return { ok: false, detail: lastDetail };
}
function summarizeError(err: unknown): string {
let raw = "unknown error";
if (err instanceof Error) {
raw = err.message || raw;
} else if (typeof err === "string") {
raw = err || raw;
} else if (err !== undefined) {
raw = inspect(err, { depth: 2 });
}
const line =
raw
.split("\n")
.map((s) => s.trim())
.find(Boolean) ?? raw;
return line.length > 120 ? `${line.slice(0, 119)}` : line;
}
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;