Skip to content
← Public packages

@kentcdodds/raycast

Raycast launcher: stored execute commands via webhooks (canonical; do not use raycast-shortcuts).

src/commands-store.ts

169 lines · 5.0 KB · TypeScript
import { packageStorage } from 'kody:runtime'

export const COMMAND_PREFIX = 'command:'

export type RaycastCommand = {
	id: string
	title: string
	description: string
	/** Raycast Icon key (e.g. "Hammer") or a single emoji. */
	icon: string
	/** Full ESM module string with `export default async function main(params)`. */
	code: string
	createdAt: string
	updatedAt: string
}

export type RaycastCommandSummary = Omit<RaycastCommand, 'code'> & {
	codeBytes: number
}

function trim(value: unknown) {
	return typeof value === 'string' ? value.trim() : ''
}

function slugify(value: string) {
	const base = value
		.toLowerCase()
		.replace(/[^a-z0-9]+/g, '-')
		.replace(/^-+|-+$/g, '')
		.slice(0, 64)
	return base || `cmd-${Date.now().toString(36)}`
}

export function commandKey(id: string) {
	return `${COMMAND_PREFIX}${id}`
}

function asCommand(value: unknown): RaycastCommand | null {
	if (!value || typeof value !== 'object' || Array.isArray(value)) return null
	const row = value as Record<string, unknown>
	const id = trim(row.id)
	const title = trim(row.title)
	const code = typeof row.code === 'string' ? row.code : ''
	if (!id || !title || !code.trim()) return null
	return {
		id,
		title,
		description: trim(row.description),
		icon: trim(row.icon) || 'Terminal',
		code,
		createdAt: trim(row.createdAt) || new Date().toISOString(),
		updatedAt: trim(row.updatedAt) || new Date().toISOString(),
	}
}

function normalizeListEntries(listed: unknown): unknown[] {
	if (Array.isArray(listed)) return listed
	if (!listed || typeof listed !== 'object') return []
	const obj = listed as Record<string, unknown>
	for (const candidate of [obj.keys, obj.entries, obj.items]) {
		if (Array.isArray(candidate)) return candidate
	}
	return []
}

function entryKey(entry: unknown): string | null {
	if (typeof entry === 'string') return entry
	if (!entry || typeof entry !== 'object') return null
	const obj = entry as Record<string, unknown>
	for (const key of ['key', 'name', 'id']) {
		if (typeof obj[key] === 'string' && obj[key]) return obj[key] as string
	}
	return null
}

function nextStartAfterOf(listed: unknown): string | null {
	if (!listed || typeof listed !== 'object') return null
	const obj = listed as Record<string, unknown>
	for (const key of ['nextStartAfter', 'cursor', 'continuationToken', 'nextCursor']) {
		const value = obj[key]
		if (typeof value === 'string' && value) return value
	}
	return null
}

function isListTruncated(listed: unknown): boolean {
	if (!listed || typeof listed !== 'object') return false
	return Boolean((listed as Record<string, unknown>).truncated)
}

export async function listCommandRecords(): Promise<RaycastCommand[]> {
	const storage = packageStorage()
	const keys: string[] = []
	let startAfter: string | undefined
	for (let page = 0; page < 40; page++) {
		const listed = await storage.list({
			prefix: COMMAND_PREFIX,
			limit: 256,
			...(startAfter ? { startAfter } : {}),
		})
		for (const entry of normalizeListEntries(listed)) {
			const key = entryKey(entry)
			if (key?.startsWith(COMMAND_PREFIX)) keys.push(key)
		}
		const next = nextStartAfterOf(listed)
		if (!isListTruncated(listed) || !next || next === startAfter) break
		startAfter = next
	}

	const out: RaycastCommand[] = []
	for (const key of keys) {
		const command = asCommand(await storage.get(key))
		if (command) out.push(command)
	}
	out.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
	return out
}

export async function getCommandRecord(id: string): Promise<RaycastCommand | null> {
	const trimmed = trim(id)
	if (!trimmed) return null
	return asCommand(await packageStorage().get(commandKey(trimmed)))
}

export function toSummary(command: RaycastCommand): RaycastCommandSummary {
	const { code, ...rest } = command
	return { ...rest, codeBytes: new TextEncoder().encode(code).length }
}

export type UpsertCommandInput = {
	id?: string
	title: string
	description?: string
	icon?: string
	code: string
}

export async function upsertCommandRecord(
	input: UpsertCommandInput,
): Promise<RaycastCommand> {
	const title = trim(input.title)
	const code = typeof input.code === 'string' ? input.code : ''
	if (!title) throw new Error('title is required.')
	if (!code.trim()) throw new Error('code is required (ESM module with export default).')

	const id = trim(input.id) || slugify(title)
	const existing = await getCommandRecord(id)
	const now = new Date().toISOString()
	const command: RaycastCommand = {
		id,
		title,
		description: trim(input.description),
		icon: trim(input.icon) || existing?.icon || 'Terminal',
		code,
		createdAt: existing?.createdAt || now,
		updatedAt: now,
	}
	await packageStorage().set(commandKey(id), command)
	return command
}

export async function deleteCommandRecord(id: string): Promise<{ deleted: boolean; id: string }> {
	const trimmed = trim(id)
	if (!trimmed) throw new Error('id is required.')
	const existing = await getCommandRecord(trimmed)
	if (!existing) return { deleted: false, id: trimmed }
	await packageStorage().delete(commandKey(trimmed))
	return { deleted: true, id: trimmed }
}