Skip to content
← 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 · TypeScript
import { 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 };