← Public packages
@kentcdodds/raycast
Raycast launcher: stored execute commands via webhooks (canonical; do not use raycast-shortcuts).
extension/src/kody-client.ts
257 lines · 7.2 KB · TypeScriptimport { getPreferenceValues, LocalStorage } from "@raycast/api";
import { homedir } from "node:os";
import { join } from "node:path";
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
type Preferences = {
webhookUrl?: string;
listCommandsWebhookUrl?: string;
runWebhookUrl?: string;
cacheTtlSeconds?: string;
cacheSwrSeconds?: string;
};
type FileConfig = {
apiBaseUrl?: string;
token?: string;
/** Preferred: single launcher router webhook. */
webhookUrl?: string;
/** @deprecated Prefer webhookUrl */
listCommandsWebhookUrl?: string;
/** @deprecated Prefer webhookUrl */
runWebhookUrl?: string;
};
export type RaycastCommandSummary = {
id: string;
title: string;
description: string;
icon: string;
codeBytes: number;
createdAt: string;
updatedAt: string;
};
const CACHE_KEY = "kody-commands-cache-v1";
const CONFIG_PATH = join(homedir(), ".config", "kody", "raycast.json");
const DEFAULT_TTL_SECONDS = 60;
const DEFAULT_SWR_SECONDS = 60 * 24 * 60 * 60; // 60 days
type CachePayload = {
fetchedAt: number;
commands: RaycastCommandSummary[];
};
async function loadFileConfig(): Promise<FileConfig | null> {
try {
const raw = await readFile(CONFIG_PATH, "utf8");
const parsed = JSON.parse(raw) as FileConfig;
if (parsed && typeof parsed === "object") return parsed;
} catch {
// missing or invalid
}
return null;
}
function prefs(): Preferences {
return getPreferenceValues<Preferences>();
}
function parseSeconds(raw: string | undefined, fallback: number) {
const seconds = Number(raw ?? fallback);
if (!Number.isFinite(seconds) || seconds < 0) return fallback;
return Math.floor(seconds);
}
function cacheTtlMs() {
return parseSeconds(prefs().cacheTtlSeconds, DEFAULT_TTL_SECONDS) * 1000;
}
function cacheSwrMs() {
return parseSeconds(prefs().cacheSwrSeconds, DEFAULT_SWR_SECONDS) * 1000;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try {
return JSON.stringify(error);
} catch {
return "Unknown error";
}
}
function normalizeResult(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const record = value as Record<string, unknown>;
if ("result" in record) return record.result;
if ("data" in record) return record.data;
if ("output" in record) return record.output;
return value;
}
async function postJson(url: string, body: unknown, headers: Record<string, string>) {
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
const text = await response.text();
let parsed: unknown = text;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
if (!response.ok) {
throw new Error(`Kody request failed (${response.status}): ${getErrorMessage(parsed)}`);
}
return normalizeResult(parsed);
}
function resolveWebhookUrl(
route: "list-commands" | "run",
fileConfig: FileConfig | null,
values: Preferences,
): { url: string; useRouterAction: boolean } {
const shared =
(fileConfig?.webhookUrl || values.webhookUrl || "").trim() || "";
if (shared) {
return { url: shared, useRouterAction: true };
}
const legacy =
(route === "list-commands"
? fileConfig?.listCommandsWebhookUrl || values.listCommandsWebhookUrl
: fileConfig?.runWebhookUrl || values.runWebhookUrl
)?.trim() || "";
if (legacy) {
// Legacy per-export URLs still work; also send action so a cutover to the
// router URL (with both fields set to the same value) is safe.
return { url: legacy, useRouterAction: true };
}
return { url: "", useRouterAction: true };
}
async function callApi<T>(
route: "list-commands" | "run",
params: Record<string, unknown>,
): Promise<T> {
const fileConfig = await loadFileConfig();
const values = prefs();
const { url: webhookUrl, useRouterAction } = resolveWebhookUrl(route, fileConfig, values);
if (webhookUrl) {
const bodyParams = useRouterAction ? { action: route, ...params } : params;
return (await postJson(
webhookUrl,
{ params: bodyParams },
{ "idempotency-key": `raycast:${route}:${randomUUID()}` },
)) as T;
}
// Package-app bearer path only works after a Kody browser handoff — keep as last resort.
if (fileConfig?.apiBaseUrl && fileConfig?.token) {
const base = fileConfig.apiBaseUrl.replace(/\/+$/, "");
return (await postJson(
`${base}/api/${route}`,
{ params },
{
authorization: `Bearer ${fileConfig.token}`,
"idempotency-key": `raycast:${route}:${randomUUID()}`,
},
)) as T;
}
throw new Error(
`Missing launcher config. Expected ${CONFIG_PATH} with webhookUrl (or legacy listCommandsWebhookUrl + runWebhookUrl).`,
);
}
async function readCache(): Promise<CachePayload | null> {
const raw = await LocalStorage.getItem<string>(CACHE_KEY);
if (!raw) return null;
try {
const cached = JSON.parse(raw) as CachePayload;
if (cached?.fetchedAt && Array.isArray(cached.commands)) return cached;
} catch {
// ignore
}
return null;
}
async function writeCache(commands: RaycastCommandSummary[]) {
await LocalStorage.setItem(
CACHE_KEY,
JSON.stringify({ fetchedAt: Date.now(), commands } satisfies CachePayload),
);
}
async function fetchCommands(): Promise<RaycastCommandSummary[]> {
const result = await callApi<{ commands: RaycastCommandSummary[] }>("list-commands", {});
const commands = Array.isArray(result?.commands) ? result.commands : [];
await writeCache(commands);
return commands;
}
export type ListCommandsOptions = {
forceRefresh?: boolean;
/** Called when a background SWR revalidation finishes with a newer list. */
onRevalidated?: (commands: RaycastCommandSummary[]) => void;
};
/**
* Stale-while-revalidate command list:
* - within TTL: return cache, no network
* - within SWR (but past TTL): return cache immediately, revalidate in background
* - past SWR / empty / forceRefresh: await network
*/
export async function listCommands(options: ListCommandsOptions = {}) {
const ttl = cacheTtlMs();
const swr = Math.max(cacheSwrMs(), ttl);
if (!options.forceRefresh) {
const cached = await readCache();
if (cached) {
const age = Date.now() - cached.fetchedAt;
if (age < ttl) {
return cached.commands;
}
if (age < swr) {
void fetchCommands()
.then((commands) => options.onRevalidated?.(commands))
.catch(() => {
// Keep serving the stale list; caller can Cmd+R to force.
});
return cached.commands;
}
}
}
return await fetchCommands();
}
export async function runCommand(input: {
commandId?: string;
code?: string;
params?: Record<string, unknown>;
}) {
return await callApi("run", {
commandId: input.commandId,
code: input.code,
params: input.params ?? {},
});
}
export function stringifyResult(value: unknown): string {
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2);
}
export { CONFIG_PATH };