The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| 4 | import type { AppFileTypeV2Input } from "../manifest/file-type"; |
| 5 | import { colineAppManifestV2Schema } from "../manifest/manifest"; |
| 6 | import { extractManifestInput } from "./extraction"; |
| 7 | import type { |
| 8 | AnyColineToolDefinitionV2, |
| 9 | ColineAppConfigV2, |
| 10 | ColineAppV2, |
| 11 | ColineCommandDefinitionV2, |
| 12 | ColineFileTypeDefinitionV2, |
| 13 | ColineFileTypeRuntime, |
| 14 | ColineToolContext, |
| 15 | ColineToolDefinitionV2, |
| 16 | ColineToolResult, |
| 17 | } from "./types"; |
| 18 |
| 19 | /** |
| 20 | * `defineApp` and friends (plan §6.1). |
| 21 | * |
| 22 | * `defineApp` performs extraction and validates the manifest eagerly, so a |
| 23 | * mis-declared app fails at module evaluation in development and in the |
| 24 | * restricted build environment — never at install time. |
| 25 | */ |
| 26 |
| 27 | export class ColineAppDefinitionError extends Error { |
| 28 | readonly issues: readonly string[]; |
| 29 |
| 30 | constructor(appKey: string, issues: readonly string[]) { |
| 31 | super( |
| 32 | `App definition "${appKey}" is invalid:\n${issues |
| 33 | .map((issue) => ` - ${issue}`) |
| 34 | .join("\n")}`, |
| 35 | ); |
| 36 | this.name = "ColineAppDefinitionError"; |
| 37 | this.issues = issues; |
| 38 | } |
| 39 | } |
| 40 |
| 41 | export function defineTool<TInput>(definition: { |
| 42 | name: string; |
| 43 | description: string; |
| 44 | input: z.ZodType<TInput>; |
| 45 | effect: ToolEffect; |
| 46 | execute: ( |
| 47 | input: TInput, |
| 48 | context: ColineToolContext, |
| 49 | ) => Promise<ColineToolResult> | ColineToolResult; |
| 50 | }): ColineToolDefinitionV2<TInput> { |
| 51 | return Object.freeze({ kind: "tool" as const, ...definition }); |
| 52 | } |
| 53 |
| 54 | export function defineFileType(definition: { |
| 55 | metadata: AppFileTypeV2Input; |
| 56 | runtime?: ColineFileTypeRuntime; |
| 57 | }): ColineFileTypeDefinitionV2 { |
| 58 | return Object.freeze({ |
| 59 | kind: "fileType" as const, |
| 60 | metadata: definition.metadata, |
| 61 | runtime: definition.runtime ?? {}, |
| 62 | }); |
| 63 | } |
| 64 |
| 65 | export function defineCommand(command: AppCommand): ColineCommandDefinitionV2 { |
| 66 | return Object.freeze({ kind: "command" as const, command }); |
| 67 | } |
| 68 |
| 69 | export function defineApp(config: ColineAppConfigV2): ColineAppV2 { |
| 70 | const manifestInput = extractManifestInput(config); |
| 71 | const parsed = colineAppManifestV2Schema.safeParse(manifestInput); |
| 72 | if (!parsed.success) { |
| 73 | throw new ColineAppDefinitionError( |
| 74 | typeof config.key === "string" ? config.key : "(unknown)", |
| 75 | parsed.error.issues.map( |
| 76 | (issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`, |
| 77 | ), |
| 78 | ); |
| 79 | } |
| 80 | const manifest = parsed.data; |
| 81 |
| 82 | const tools = new Map<string, AnyColineToolDefinitionV2>(); |
| 83 | for (const tool of config.tools ?? []) { |
| 84 | tools.set(tool.name, tool); |
| 85 | } |
| 86 |
| 87 | const fileTypes = new Map<string, ColineFileTypeRuntime>(); |
| 88 | const runtimeIssues: string[] = []; |
| 89 | for (const fileType of config.files ?? []) { |
| 90 | fileTypes.set(fileType.metadata.typeKey, fileType.runtime); |
| 91 | } |
| 92 |
| 93 | // Runtime completeness: every tree-tier surface needs its handler. |
| 94 | const manifestFilesByKey = new Map(manifest.files.map((file) => [file.typeKey, file])); |
| 95 | for (const [typeKey, runtime] of fileTypes) { |
| 96 | const declared = manifestFilesByKey.get(typeKey); |
| 97 | if (!declared) continue; |
| 98 | if (declared.surfaces.editor?.tier === "tree" && !runtime.renderEditor) { |
| 99 | runtimeIssues.push(`files.${typeKey}: tree editor surface declared without a renderEditor handler.`); |
| 100 | } |
| 101 | if (declared.surfaces.preview && !runtime.renderPreview) { |
| 102 | runtimeIssues.push(`files.${typeKey}: preview surface declared without a renderPreview handler.`); |
| 103 | } |
| 104 | if (declared.surfaces.inline && !runtime.renderInline) { |
| 105 | runtimeIssues.push(`files.${typeKey}: inline surface declared without a renderInline handler.`); |
| 106 | } |
| 107 | if (declared.indexable && declared.storage !== "external_pointer" && !runtime.index) { |
| 108 | runtimeIssues.push(`files.${typeKey}: indexable file types need index extractors (or set indexable: false).`); |
| 109 | } |
| 110 | } |
| 111 | if (manifest.surfaces.home?.tier === "tree" && !config.handlers?.renderHome) { |
| 112 | runtimeIssues.push("surfaces.home: tree home surface declared without a handlers.renderHome."); |
| 113 | } |
| 114 | if (runtimeIssues.length > 0) { |
| 115 | throw new ColineAppDefinitionError(manifest.key, runtimeIssues); |
| 116 | } |
| 117 |
| 118 | return Object.freeze({ |
| 119 | kind: "app" as const, |
| 120 | manifest, |
| 121 | runtime: Object.freeze({ |
| 122 | tools, |
| 123 | fileTypes, |
| 124 | renderHome: config.handlers?.renderHome ?? null, |
| 125 | }), |
| 126 | }); |
| 127 | } |
| 128 |