refactor: move tlon to setup wizard

This commit is contained in:
Peter Steinberger 2026-03-15 17:56:40 -07:00
parent a8bee6fb6c
commit 8c71b36acb
No known key found for this signature in database
4 changed files with 375 additions and 315 deletions

View File

@ -3,18 +3,11 @@ import { configureClient } from "@tloncorp/api";
import type {
ChannelOutboundAdapter,
ChannelPlugin,
ChannelSetupInput,
OpenClawConfig,
} from "openclaw/plugin-sdk/tlon";
import {
applyAccountNameToChannelSection,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "openclaw/plugin-sdk/tlon";
import { buildTlonAccountFields } from "./account-fields.js";
import { tlonChannelConfigSchema } from "./config-schema.js";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonOnboardingAdapter } from "./onboarding.js";
import { tlonSetupAdapter, tlonSetupWizard } from "./setup-surface.js";
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
import { authenticate } from "./urbit/auth.js";
@ -89,70 +82,6 @@ async function createHttpPokeApi(params: {
const TLON_CHANNEL_ID = "tlon" as const;
type TlonSetupInput = ChannelSetupInput & {
ship?: string;
url?: string;
code?: string;
allowPrivateNetwork?: boolean;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
ownerShip?: string;
};
function applyTlonSetupConfig(params: {
cfg: OpenClawConfig;
accountId: string;
input: TlonSetupInput;
}): OpenClawConfig {
const { cfg, accountId, input } = params;
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "tlon",
accountId,
name: input.name,
});
const base = namedConfig.channels?.tlon ?? {};
const payload = buildTlonAccountFields(input);
if (useDefault) {
return {
...namedConfig,
channels: {
...namedConfig.channels,
tlon: {
...base,
enabled: true,
...payload,
},
},
};
}
return {
...namedConfig,
channels: {
...namedConfig.channels,
tlon: {
...base,
enabled: base.enabled ?? true,
accounts: {
...(base as { accounts?: Record<string, unknown> }).accounts,
[accountId]: {
...(base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
accountId
],
enabled: true,
...payload,
},
},
},
},
};
}
type ResolvedTlonAccount = ReturnType<typeof resolveTlonAccount>;
type ConfiguredTlonAccount = ResolvedTlonAccount & {
ship: string;
@ -296,7 +225,8 @@ export const tlonPlugin: ChannelPlugin = {
reply: true,
threads: true,
},
onboarding: tlonOnboardingAdapter,
setup: tlonSetupAdapter,
setupWizard: tlonSetupWizard,
reload: { configPrefixes: ["channels.tlon"] },
configSchema: tlonChannelConfigSchema,
config: {
@ -374,39 +304,6 @@ export const tlonPlugin: ChannelPlugin = {
url: account.url,
}),
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg,
channelKey: "tlon",
accountId,
name,
}),
validateInput: ({ cfg, accountId, input }) => {
const setupInput = input as TlonSetupInput;
const resolved = resolveTlonAccount(cfg, accountId ?? undefined);
const ship = setupInput.ship?.trim() || resolved.ship;
const url = setupInput.url?.trim() || resolved.url;
const code = setupInput.code?.trim() || resolved.code;
if (!ship) {
return "Tlon requires --ship.";
}
if (!url) {
return "Tlon requires --url.";
}
if (!code) {
return "Tlon requires --code.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) =>
applyTlonSetupConfig({
cfg: cfg,
accountId,
input: input as TlonSetupInput,
}),
},
messaging: {
normalizeTarget: (target) => {
const parsed = parseTlonTarget(target);

View File

@ -1,209 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon";
import {
formatDocsLink,
patchScopedAccountConfig,
resolveAccountIdForConfigure,
DEFAULT_ACCOUNT_ID,
type ChannelOnboardingAdapter,
type WizardPrompter,
} from "openclaw/plugin-sdk/tlon";
import { buildTlonAccountFields } from "./account-fields.js";
import type { TlonResolvedAccount } from "./types.js";
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js";
const channel = "tlon" as const;
function isConfigured(account: TlonResolvedAccount): boolean {
return Boolean(account.ship && account.url && account.code);
}
function applyAccountConfig(params: {
cfg: OpenClawConfig;
accountId: string;
input: {
name?: string;
ship?: string;
url?: string;
code?: string;
allowPrivateNetwork?: boolean;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
};
}): OpenClawConfig {
const { cfg, accountId, input } = params;
const nextValues = {
enabled: true,
...(input.name ? { name: input.name } : {}),
...buildTlonAccountFields(input),
};
if (accountId === DEFAULT_ACCOUNT_ID) {
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId,
patch: nextValues,
ensureChannelEnabled: false,
ensureAccountEnabled: false,
});
}
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId,
patch: { enabled: cfg.channels?.tlon?.enabled ?? true },
accountPatch: nextValues,
ensureChannelEnabled: false,
ensureAccountEnabled: false,
});
}
async function noteTlonHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"You need your Urbit ship URL and login code.",
"Example URL: https://your-ship-host",
"Example ship: ~sampel-palnet",
"If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.",
`Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`,
].join("\n"),
"Tlon setup",
);
}
function parseList(value: string): string[] {
return value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
export const tlonOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const accountIds = listTlonAccountIds(cfg);
const configured =
accountIds.length > 0
? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId)))
: isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID));
return {
channel,
configured,
statusLines: [`Tlon: ${configured ? "configured" : "needs setup"}`],
selectionHint: configured ? "configured" : "urbit messenger",
quickstartScore: configured ? 1 : 4,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const defaultAccountId = DEFAULT_ACCOUNT_ID;
const accountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "Tlon",
accountOverride: accountOverrides[channel],
shouldPromptAccountIds,
listAccountIds: listTlonAccountIds,
defaultAccountId,
});
const resolved = resolveTlonAccount(cfg, accountId);
await noteTlonHelp(prompter);
const ship = await prompter.text({
message: "Ship name",
placeholder: "~sampel-palnet",
initialValue: resolved.ship ?? undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const url = await prompter.text({
message: "Ship URL",
placeholder: "https://your-ship-host",
initialValue: resolved.url ?? undefined,
validate: (value) => {
const next = validateUrbitBaseUrl(String(value ?? ""));
if (!next.ok) {
return next.error;
}
return undefined;
},
});
const validatedUrl = validateUrbitBaseUrl(String(url).trim());
if (!validatedUrl.ok) {
throw new Error(`Invalid URL: ${validatedUrl.error}`);
}
let allowPrivateNetwork = resolved.allowPrivateNetwork ?? false;
if (isBlockedUrbitHostname(validatedUrl.hostname)) {
allowPrivateNetwork = await prompter.confirm({
message:
"Ship URL looks like a private/internal host. Allow private network access? (SSRF risk)",
initialValue: allowPrivateNetwork,
});
if (!allowPrivateNetwork) {
throw new Error("Refusing private/internal Ship URL without explicit approval");
}
}
const code = await prompter.text({
message: "Login code",
placeholder: "lidlut-tabwed-pillex-ridrup",
initialValue: resolved.code ?? undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const wantsGroupChannels = await prompter.confirm({
message: "Add group channels manually? (optional)",
initialValue: false,
});
let groupChannels: string[] | undefined;
if (wantsGroupChannels) {
const entry = await prompter.text({
message: "Group channels (comma-separated)",
placeholder: "chat/~host-ship/general, chat/~host-ship/support",
});
const parsed = parseList(String(entry ?? ""));
groupChannels = parsed.length > 0 ? parsed : undefined;
}
const wantsAllowlist = await prompter.confirm({
message: "Restrict DMs with an allowlist?",
initialValue: false,
});
let dmAllowlist: string[] | undefined;
if (wantsAllowlist) {
const entry = await prompter.text({
message: "DM allowlist (comma-separated ship names)",
placeholder: "~zod, ~nec",
});
const parsed = parseList(String(entry ?? ""));
dmAllowlist = parsed.length > 0 ? parsed : undefined;
}
const autoDiscoverChannels = await prompter.confirm({
message: "Enable auto-discovery of group channels?",
initialValue: resolved.autoDiscoverChannels ?? true,
});
const next = applyAccountConfig({
cfg,
accountId,
input: {
ship: String(ship).trim(),
url: String(url).trim(),
code: String(code).trim(),
allowPrivateNetwork,
groupChannels,
dmAllowlist,
autoDiscoverChannels,
},
});
return { cfg: next, accountId };
},
};

View File

@ -0,0 +1,94 @@
import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/tlon";
import { describe, expect, it, vi } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { tlonPlugin } from "./channel.js";
const selectFirstOption = async <T>(params: { options: Array<{ value: T }> }): Promise<T> => {
const first = params.options[0];
if (!first) {
throw new Error("no options");
}
return first.value;
};
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: selectFirstOption as WizardPrompter["select"],
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
};
}
const tlonConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
plugin: tlonPlugin,
wizard: tlonPlugin.setupWizard!,
});
describe("tlon setup wizard", () => {
it("configures ship, auth, and discovery settings", async () => {
const prompter = createPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Ship name") {
return "sampel-palnet";
}
if (message === "Ship URL") {
return "https://urbit.example.com";
}
if (message === "Login code") {
return "lidlut-tabwed-pillex-ridrup";
}
if (message === "Group channels (comma-separated)") {
return "chat/~host-ship/general, chat/~host-ship/support";
}
if (message === "DM allowlist (comma-separated ship names)") {
return "~zod, nec";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Add group channels manually? (optional)") {
return true;
}
if (message === "Restrict DMs with an allowlist?") {
return true;
}
if (message === "Enable auto-discovery of group channels?") {
return true;
}
return false;
}),
});
const runtime: RuntimeEnv = createRuntimeEnv();
const result = await tlonConfigureAdapter.configure({
cfg: {} as OpenClawConfig,
runtime,
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.tlon?.enabled).toBe(true);
expect(result.cfg.channels?.tlon?.ship).toBe("~sampel-palnet");
expect(result.cfg.channels?.tlon?.url).toBe("https://urbit.example.com");
expect(result.cfg.channels?.tlon?.code).toBe("lidlut-tabwed-pillex-ridrup");
expect(result.cfg.channels?.tlon?.groupChannels).toEqual([
"chat/~host-ship/general",
"chat/~host-ship/support",
]);
expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]);
expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true);
expect(result.cfg.channels?.tlon?.allowPrivateNetwork).toBe(false);
});
});

View File

@ -0,0 +1,278 @@
import {
applyAccountNameToChannelSection,
patchScopedAccountConfig,
} from "../../../src/channels/plugins/setup-helpers.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { buildTlonAccountFields } from "./account-fields.js";
import { normalizeShip } from "./targets.js";
import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js";
import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js";
const channel = "tlon" as const;
type TlonSetupInput = ChannelSetupInput & {
ship?: string;
url?: string;
code?: string;
allowPrivateNetwork?: boolean;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
ownerShip?: string;
};
function isConfigured(account: TlonResolvedAccount): boolean {
return Boolean(account.ship && account.url && account.code);
}
function parseList(value: string): string[] {
return value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function applyTlonSetupConfig(params: {
cfg: OpenClawConfig;
accountId: string;
input: TlonSetupInput;
}): OpenClawConfig {
const { cfg, accountId, input } = params;
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: input.name,
});
const base = namedConfig.channels?.tlon ?? {};
const payload = buildTlonAccountFields(input);
if (useDefault) {
return {
...namedConfig,
channels: {
...namedConfig.channels,
tlon: {
...base,
enabled: true,
...payload,
},
},
};
}
return patchScopedAccountConfig({
cfg: namedConfig,
channelKey: channel,
accountId,
patch: { enabled: base.enabled ?? true },
accountPatch: {
enabled: true,
...payload,
},
ensureChannelEnabled: false,
ensureAccountEnabled: false,
});
}
export const tlonSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
validateInput: ({ cfg, accountId, input }) => {
const setupInput = input as TlonSetupInput;
const resolved = resolveTlonAccount(cfg, accountId ?? undefined);
const ship = setupInput.ship?.trim() || resolved.ship;
const url = setupInput.url?.trim() || resolved.url;
const code = setupInput.code?.trim() || resolved.code;
if (!ship) {
return "Tlon requires --ship.";
}
if (!url) {
return "Tlon requires --url.";
}
if (!code) {
return "Tlon requires --code.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: input as TlonSetupInput,
}),
};
export const tlonSetupWizard: ChannelSetupWizard = {
channel,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "configured",
unconfiguredHint: "urbit messenger",
configuredScore: 1,
unconfiguredScore: 4,
resolveConfigured: ({ cfg }) => {
const accountIds = listTlonAccountIds(cfg);
return accountIds.length > 0
? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId)))
: isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID));
},
resolveStatusLines: ({ cfg }) => {
const accountIds = listTlonAccountIds(cfg);
const configured =
accountIds.length > 0
? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId)))
: isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID));
return [`Tlon: ${configured ? "configured" : "needs setup"}`];
},
},
introNote: {
title: "Tlon setup",
lines: [
"You need your Urbit ship URL and login code.",
"Example URL: https://your-ship-host",
"Example ship: ~sampel-palnet",
"If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.",
`Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`,
],
},
credentials: [],
textInputs: [
{
inputKey: "ship",
message: "Ship name",
placeholder: "~sampel-palnet",
currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).ship ?? undefined,
validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"),
normalizeValue: ({ value }) => normalizeShip(String(value).trim()),
applySet: async ({ cfg, accountId, value }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: { ship: value },
}),
},
{
inputKey: "url",
message: "Ship URL",
placeholder: "https://your-ship-host",
currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).url ?? undefined,
validate: ({ value }) => {
const next = validateUrbitBaseUrl(String(value ?? ""));
if (!next.ok) {
return next.error;
}
return undefined;
},
normalizeValue: ({ value }) => String(value).trim(),
applySet: async ({ cfg, accountId, value }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: { url: value },
}),
},
{
inputKey: "code",
message: "Login code",
placeholder: "lidlut-tabwed-pillex-ridrup",
currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).code ?? undefined,
validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"),
normalizeValue: ({ value }) => String(value).trim(),
applySet: async ({ cfg, accountId, value }) =>
applyTlonSetupConfig({
cfg,
accountId,
input: { code: value },
}),
},
],
finalize: async ({ cfg, accountId, prompter }) => {
let next = cfg;
const resolved = resolveTlonAccount(next, accountId);
const validatedUrl = validateUrbitBaseUrl(resolved.url ?? "");
if (!validatedUrl.ok) {
throw new Error(`Invalid URL: ${validatedUrl.error}`);
}
let allowPrivateNetwork = resolved.allowPrivateNetwork ?? false;
if (isBlockedUrbitHostname(validatedUrl.hostname)) {
allowPrivateNetwork = await prompter.confirm({
message:
"Ship URL looks like a private/internal host. Allow private network access? (SSRF risk)",
initialValue: allowPrivateNetwork,
});
if (!allowPrivateNetwork) {
throw new Error("Refusing private/internal Ship URL without explicit approval");
}
}
next = applyTlonSetupConfig({
cfg: next,
accountId,
input: { allowPrivateNetwork },
});
const currentGroups = resolved.groupChannels;
const wantsGroupChannels = await prompter.confirm({
message: "Add group channels manually? (optional)",
initialValue: currentGroups.length > 0,
});
if (wantsGroupChannels) {
const entry = await prompter.text({
message: "Group channels (comma-separated)",
placeholder: "chat/~host-ship/general, chat/~host-ship/support",
initialValue: currentGroups.join(", ") || undefined,
});
next = applyTlonSetupConfig({
cfg: next,
accountId,
input: { groupChannels: parseList(String(entry ?? "")) },
});
}
const currentAllowlist = resolved.dmAllowlist;
const wantsAllowlist = await prompter.confirm({
message: "Restrict DMs with an allowlist?",
initialValue: currentAllowlist.length > 0,
});
if (wantsAllowlist) {
const entry = await prompter.text({
message: "DM allowlist (comma-separated ship names)",
placeholder: "~zod, ~nec",
initialValue: currentAllowlist.join(", ") || undefined,
});
next = applyTlonSetupConfig({
cfg: next,
accountId,
input: {
dmAllowlist: parseList(String(entry ?? "")).map((ship) => normalizeShip(ship)),
},
});
}
const autoDiscoverChannels = await prompter.confirm({
message: "Enable auto-discovery of group channels?",
initialValue: resolved.autoDiscoverChannels ?? true,
});
next = applyTlonSetupConfig({
cfg: next,
accountId,
input: { autoDiscoverChannels },
});
return { cfg: next };
},
};