Skip to content

src/invoke.ts

110 lines · 3.4 KB · TypeScript
Raw
import getPackage from './get-package.ts'
import { asRecord, isDryRun, optionalString } from './helpers.ts'

export type InvokeInput = {
	kodyId?: string
	packageId?: string
	exportName: string
	params?: Record<string, unknown>
	/**
	 * Deprecated: packages.invoke sunset (#1750 / codemod 0008). Exactly-once
	 * is not available on this lean path — use a workflow. The key is ignored.
	 */
	idempotencyKey?: string
	/** Deprecated: ignored after packages.invoke sunset. */
	topic?: string
	dryRun?: boolean
}

/**
 * Invoke one of the caller's saved package exports from an external client.
 *
 * HTTP callers authenticate against this `raycast` package, then pass the
 * target `kodyId` (or `packageId`) and `exportName` here. The target loads via
 * computed `import(specifier)` (name is data).
 *
 * Pass `dryRun: true` to resolve the target and preview the call without
 * invoking it. Live invokes do not default to dry-run — this export is the
 * launcher front door.
 *
 * @param params.kodyId - Target package kody.id (preferred).
 * @param params.packageId - Target saved package id when kodyId is omitted.
 * @param params.exportName - Export to run (`./list-packages`, `list-packages`, or `.`).
 * @param params.params - JSON object passed to the target export.
 * @param params.dryRun - When true, preview without calling the target.
 * @returns The target export's unwrapped return value, or a dry-run preview.
 *
 * @example
 * import invoke from 'kody:@kody/raycast/invoke'
 * const preview = await invoke({
 *   kodyId: 'notify',
 *   exportName: '.',
 *   params: { dryRun: true },
 *   dryRun: true,
 * })
 */
export default async function invoke(input: InvokeInput) {
	const record = asRecord(input)
	const exportName = optionalString(record, 'exportName')
	if (!exportName) {
		throw new Error('exportName is required.')
	}

	const kodyId = optionalString(record, 'kodyId')
	const packageId = optionalString(record, 'packageId')
	if (!kodyId && !packageId) {
		throw new Error('Pass kodyId or packageId.')
	}

	const params =
		record.params && typeof record.params === 'object' && !Array.isArray(record.params)
			? (record.params as Record<string, unknown>)
			: {}
	// idempotencyKey / topic intentionally ignored (no packages.invoke; no workflow).
	const idempotencyKey = optionalString(record, 'idempotencyKey')
	const topic = optionalString(record, 'topic')
	void idempotencyKey
	void topic

	const target = await getPackage({ packageId, kodyId })
	const matched = target.exports.find(entry => {
		return (
			entry.subpath === exportName ||
			entry.exportName === exportName ||
			entry.exportName === exportName.replace(/^\.\//, '') ||
			(exportName === '.' && entry.subpath === '.')
		)
	})
	if (!matched) {
		throw new Error(`Package ${target.name} does not export ${exportName}.`)
	}

	if (isDryRun(record)) {
		return {
			dryRun: true,
			wouldInvoke: true,
			kodyId: target.kodyId,
			packageId: target.packageId,
			name: target.name,
			exportName,
			matchedExport: {
				subpath: matched.subpath,
				exportName: matched.exportName,
				importSpecifier: matched.importSpecifier,
				description: matched.description,
			},
			params,
			idempotencyKey: null,
			topic: null,
		}
	}

	const raw = matched.importSpecifier
	const trimmed = typeof raw === 'string' ? raw.trim() : ''
	const specifier =
		typeof raw === 'string' && trimmed.startsWith('@')
			? `kody:${trimmed}`
			: trimmed
	const mod = await import(specifier)
	return await mod.default(params)
}