refactor: move tlon to setup wizard
This commit is contained in:
parent
a8bee6fb6c
commit
8c71b36acb
@ -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);
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
};
|
||||
94
extensions/tlon/src/setup-surface.test.ts
Normal file
94
extensions/tlon/src/setup-surface.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
278
extensions/tlon/src/setup-surface.ts
Normal file
278
extensions/tlon/src/setup-surface.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user