- Replaced the custom source configuration logic with a new `saveSourceWithCredentials` function in `AddSourceDialog`. - Updated `useAddSourceFormState` to manage authentication state revisions more effectively. - Removed unused functions and streamlined error handling related to credential input. - Enhanced user feedback on source addition and updates with clearer success messages.
Unified · Split
| @@ -17,19 +17,15 @@ import { convexApi } from "@/lib/convex-api"; · 16 unmodified lines | |||
| 17 | import type { CredentialRecord, ToolSourceRecord } from "@/lib/types"; | 17 | import type { CredentialRecord, ToolSourceRecord } from "@/lib/types"; |
| 18 | import { workspaceQueryArgs } from "@/lib/workspace-query-args"; | 18 | import { workspaceQueryArgs } from "@/lib/workspace-query-args"; |
| 19 | import type { CatalogCollectionItem } from "@/lib/catalog-collections"; | 19 | import type { CatalogCollectionItem } from "@/lib/catalog-collections"; |
| 20 | import { sourceKeyForSource } from "@/lib/tools-source-helpers"; | ||
| 21 | import { | ||
| 22 | createCustomSourceConfig, | ||
| 23 | } from "./add-source-dialog-helpers"; | ||
| 24 | import { | 20 | import { |
| 25 | CatalogViewSection, | 21 | CatalogViewSection, |
| 26 | CustomViewSection, | 22 | CustomViewSection, |
| 27 | } from "./add-source-dialog-sections"; | 23 | } from "./add-source-dialog-sections"; |
| 28 | import { SourceAuthPanel } from "./add-source-auth-panel"; | 24 | import { SourceAuthPanel } from "./add-source-auth-panel"; |
| 29 | import { | 25 | import { |
| 30 | existingCredentialMatchesAuthType, | ||
| 31 | useAddSourceFormState, | 26 | useAddSourceFormState, |
| 32 | } from "./use-add-source-form-state"; | 27 | } from "./use-add-source-form-state"; |
| 28 | import { saveSourceWithCredentials } from "./add-source-submit"; | ||
| 33 | 29 | ||
| 34 | export function AddSourceDialog({ | 30 | export function AddSourceDialog({ |
| 35 | existingSourceNames, | 31 | existingSourceNames, |
| @@ -90,76 +86,34 @@ export function AddSourceDialog({ · 54 unmodified lines | |||
| 90 | 86 | ||
| 91 | setSubmitting(true); | 87 | setSubmitting(true); |
| 92 | try { | 88 | try { |
| 93 | const authConfig = form.type === "openapi" || form.type === "graphql" | 89 | const result = await saveSourceWithCredentials({ |
| 94 | ? form.buildAuthConfig() | 90 | context, |
| 95 | : undefined; | 91 | sourceToEdit, |
| 96 | const config = createCustomSourceConfig({ | 92 | credentialsLoading, |
| 97 | type: form.type, | 93 | upsertToolSource, |
| 98 | endpoint: form.endpoint.trim(), | 94 | upsertCredential, |
| 99 | baseUrl: form.baseUrl, | 95 | form: { |
| 100 | auth: authConfig, | 96 | name: form.name, |
| 101 | mcpTransport: form.mcpTransport, | 97 | endpoint: form.endpoint, |
| 102 | actorId: context.actorId, | 98 | type: form.type, |
| 99 | baseUrl: form.baseUrl, | ||
| 100 | mcpTransport: form.mcpTransport, | ||
| 101 | authType: form.authType, | ||
| 102 | authScope: form.authScope, | ||
| 103 | existingScopedCredential: form.existingScopedCredential, | ||
| 104 | buildAuthConfig: form.buildAuthConfig, | ||
| 105 | hasCredentialInput: form.hasCredentialInput, | ||
| 106 | buildSecretJson: form.buildSecretJson, | ||
| 107 | }, | ||
| 103 | }); | 108 | }); |
| 104 | 109 | ||
| 105 | const created = await upsertToolSource({ | 110 | onSourceAdded?.(result.source, { connected: result.connected }); |
| 106 | ...(sourceToEdit ? { id: sourceToEdit.id } : {}), | ||
| 107 | workspaceId: context.workspaceId, | ||
| 108 | sessionId: context.sessionId, | ||
| 109 | name: form.name.trim(), | ||
| 110 | type: form.type, | ||
| 111 | config, | ||
| 112 | }) as ToolSourceRecord; | ||
| 113 | let linkedCredential = false; | ||
| 114 | |||
| 115 | if ((form.type === "openapi" || form.type === "graphql") && form.authType !== "none") { | ||
| 116 | const sourceKey = sourceKeyForSource(created); | ||
| 117 | if (!sourceKey) { | ||
| 118 | throw new Error("Failed to resolve source key for credentials"); | ||
| 119 | } | ||
| 120 | |||
| 121 | if (form.authScope === "actor" && !context.actorId) { | ||
| 122 | throw new Error("Actor credentials require an authenticated actor"); | ||
| 123 | } | ||
| 124 | |||
| 125 | const enteredCredential = form.hasCredentialInput(); | ||
| 126 | if (!enteredCredential && credentialsLoading) { | ||
| 127 | throw new Error("Loading existing connections, try again in a moment"); | ||
| 128 | } | ||
| 129 | |||
| 130 | if (enteredCredential) { | ||
| 131 | const secret = form.buildSecretJson(); | ||
| 132 | if (!secret.value) { | ||
| 133 | throw new Error(secret.error ?? "Credential values are required"); | ||
| 134 | } | ||
| 135 | |||
| 136 | await upsertCredential({ | ||
| 137 | ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}), | ||
| 138 | workspaceId: context.workspaceId, | ||
| 139 | sessionId: context.sessionId, | ||
| 140 | sourceKey, | ||
| 141 | scope: form.authScope, | ||
| 142 | ...(form.authScope === "actor" ? { actorId: context.actorId } : {}), | ||
| 143 | secretJson: secret.value, | ||
| 144 | }); | ||
| 145 | linkedCredential = true; | ||
| 146 | } else if (form.existingScopedCredential) { | ||
| 147 | if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) { | ||
| 148 | throw new Error("Enter credentials for the selected auth type"); | ||
| 149 | } | ||
| 150 | linkedCredential = true; | ||
| 151 | } else { | ||
| 152 | throw new Error("Enter credentials to finish setup"); | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | onSourceAdded?.(created, { connected: linkedCredential }); | ||
| 157 | toast.success( | 111 | toast.success( |
| 158 | sourceToEdit | 112 | sourceToEdit |
| 159 | ? linkedCredential | 113 | ? result.connected |
| 160 | ? `Source "${form.name.trim()}" updated with credentials` | 114 | ? `Source "${form.name.trim()}" updated with credentials` |
| 161 | : `Source "${form.name.trim()}" updated` | 115 | : `Source "${form.name.trim()}" updated` |
| 162 | : linkedCredential | 116 | : result.connected |
| 163 | ? `Source "${form.name.trim()}" added with credentials — loading tools…` | 117 | ? `Source "${form.name.trim()}" added with credentials — loading tools…` |
| 164 | : `Source "${form.name.trim()}" added — loading tools…`, | 118 | : `Source "${form.name.trim()}" added — loading tools…`, |
| 165 | ); | 119 | ); |
| @@ -0,0 +1,129 @@ | |||
| 1 | import { sourceKeyForSource } from "@/lib/tools-source-helpers"; | ||
| 2 | import type { | ||
| 3 | AnonymousContext, | ||
| 4 | CredentialRecord, | ||
| 5 | CredentialScope, | ||
| 6 | SourceAuthType, | ||
| 7 | ToolSourceRecord, | ||
| 8 | } from "@/lib/types"; | ||
| 9 | import { createCustomSourceConfig, type SourceType } from "./add-source-dialog-helpers"; | ||
| 10 | import { existingCredentialMatchesAuthType } from "./add-source-form-utils"; | ||
| 11 | |||
| 12 | type UpsertToolSourceFn = (args: { | ||
| 13 | id?: ToolSourceRecord["id"]; | ||
| 14 | workspaceId: AnonymousContext["workspaceId"]; | ||
| 15 | sessionId: AnonymousContext["sessionId"]; | ||
| 16 | name: string; | ||
| 17 | type: SourceType; | ||
| 18 | config: Record<string, unknown>; | ||
| 19 | }) => Promise<unknown>; | ||
| 20 | |||
| 21 | type UpsertCredentialFn = (args: { | ||
| 22 | id?: CredentialRecord["id"]; | ||
| 23 | workspaceId: AnonymousContext["workspaceId"]; | ||
| 24 | sessionId: AnonymousContext["sessionId"]; | ||
| 25 | sourceKey: string; | ||
| 26 | scope: CredentialScope; | ||
| 27 | actorId?: AnonymousContext["actorId"]; | ||
| 28 | secretJson: Record<string, unknown>; | ||
| 29 | }) => Promise<unknown>; | ||
| 30 | |||
| 31 | type SaveFormSnapshot = { | ||
| 32 | name: string; | ||
| 33 | endpoint: string; | ||
| 34 | type: SourceType; | ||
| 35 | baseUrl: string; | ||
| 36 | mcpTransport: "auto" | "streamable-http" | "sse"; | ||
| 37 | authType: Exclude<SourceAuthType, "mixed">; | ||
| 38 | authScope: CredentialScope; | ||
| 39 | existingScopedCredential: CredentialRecord | null; | ||
| 40 | buildAuthConfig: () => Record<string, unknown> | undefined; | ||
| 41 | hasCredentialInput: () => boolean; | ||
| 42 | buildSecretJson: () => { value?: Record<string, unknown>; error?: string }; | ||
| 43 | }; | ||
| 44 | |||
| 45 | export async function saveSourceWithCredentials({ | ||
| 46 | context, | ||
| 47 | sourceToEdit, | ||
| 48 | form, | ||
| 49 | credentialsLoading, | ||
| 50 | upsertToolSource, | ||
| 51 | upsertCredential, | ||
| 52 | }: { | ||
| 53 | context: AnonymousContext; | ||
| 54 | sourceToEdit?: ToolSourceRecord; | ||
| 55 | form: SaveFormSnapshot; | ||
| 56 | credentialsLoading: boolean; | ||
| 57 | upsertToolSource: UpsertToolSourceFn; | ||
| 58 | upsertCredential: UpsertCredentialFn; | ||
| 59 | }): Promise<{ source: ToolSourceRecord; connected: boolean }> { | ||
| 60 | const authConfig = form.type === "openapi" || form.type === "graphql" | ||
| 61 | ? form.buildAuthConfig() | ||
| 62 | : undefined; | ||
| 63 | |||
| 64 | const config = createCustomSourceConfig({ | ||
| 65 | type: form.type, | ||
| 66 | endpoint: form.endpoint.trim(), | ||
| 67 | baseUrl: form.baseUrl, | ||
| 68 | auth: authConfig, | ||
| 69 | mcpTransport: form.mcpTransport, | ||
| 70 | actorId: context.actorId, | ||
| 71 | }); | ||
| 72 | |||
| 73 | const created = await upsertToolSource({ | ||
| 74 | ...(sourceToEdit ? { id: sourceToEdit.id } : {}), | ||
| 75 | workspaceId: context.workspaceId, | ||
| 76 | sessionId: context.sessionId, | ||
| 77 | name: form.name.trim(), | ||
| 78 | type: form.type, | ||
| 79 | config, | ||
| 80 | }) as ToolSourceRecord; | ||
| 81 | |||
| 82 | let linkedCredential = false; | ||
| 83 | |||
| 84 | if ((form.type === "openapi" || form.type === "graphql") && form.authType !== "none") { | ||
| 85 | const sourceKey = sourceKeyForSource(created); | ||
| 86 | if (!sourceKey) { | ||
| 87 | throw new Error("Failed to resolve source key for credentials"); | ||
| 88 | } | ||
| 89 | |||
| 90 | if (form.authScope === "actor" && !context.actorId) { | ||
| 91 | throw new Error("Actor credentials require an authenticated actor"); | ||
| 92 | } | ||
| 93 | |||
| 94 | const enteredCredential = form.hasCredentialInput(); | ||
| 95 | if (!enteredCredential && credentialsLoading) { | ||
| 96 | throw new Error("Loading existing connections, try again in a moment"); | ||
| 97 | } | ||
| 98 | |||
| 99 | if (enteredCredential) { | ||
| 100 | const secret = form.buildSecretJson(); | ||
| 101 | if (!secret.value) { | ||
| 102 | throw new Error(secret.error ?? "Credential values are required"); | ||
| 103 | } | ||
| 104 | |||
| 105 | await upsertCredential({ | ||
| 106 | ...(form.existingScopedCredential ? { id: form.existingScopedCredential.id } : {}), | ||
| 107 | workspaceId: context.workspaceId, | ||
| 108 | sessionId: context.sessionId, | ||
| 109 | sourceKey, | ||
| 110 | scope: form.authScope, | ||
| 111 | ...(form.authScope === "actor" ? { actorId: context.actorId } : {}), | ||
| 112 | secretJson: secret.value, | ||
| 113 | }); | ||
| 114 | linkedCredential = true; | ||
| 115 | } else if (form.existingScopedCredential) { | ||
| 116 | if (!existingCredentialMatchesAuthType(form.existingScopedCredential, form.authType)) { | ||
| 117 | throw new Error("Enter credentials for the selected auth type"); | ||
| 118 | } | ||
| 119 | linkedCredential = true; | ||
| 120 | } else { | ||
| 121 | throw new Error("Enter credentials to finish setup"); | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | return { | ||
| 126 | source: created, | ||
| 127 | connected: linkedCredential, | ||
| 128 | }; | ||
| 129 | } | ||
| @@ -35,6 +35,17 @@ function patchUi( · 34 unmodified lines | |||
| 35 | setUi((current) => ({ ...current, ...patch })); | 35 | setUi((current) => ({ ...current, ...patch })); |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | function patchUiWithAuthRevision( | ||
| 39 | setUi: Dispatch<SetStateAction<AddSourceUiState>>, | ||
| 40 | patch: Partial<AddSourceUiState> = {}, | ||
| 41 | ) { | ||
| 42 | setUi((current) => ({ | ||
| 43 | ...current, | ||
| 44 | ...patch, | ||
| 45 | authRevision: current.authRevision + 1, | ||
| 46 | })); | ||
| 47 | } | ||
| 48 | |||
| 38 | export function useAddSourceFormState({ | 49 | export function useAddSourceFormState({ |
| 39 | open, | 50 | open, |
| 40 | sourceToEdit, | 51 | sourceToEdit, |
| @@ -88,7 +99,7 @@ export function useAddSourceFormState({ · 47 unmodified lines | |||
| 88 | if (sourceToEdit) { | 99 | if (sourceToEdit) { |
| 89 | const editValues = sourceToFormValues(sourceToEdit); | 100 | const editValues = sourceToFormValues(sourceToEdit); |
| 90 | form.reset(editValues); | 101 | form.reset(editValues); |
| 91 | patchUi(setUi, { | 102 | patchUiWithAuthRevision(setUi, { |
| 92 | ...createDefaultUiState("custom"), | 103 | ...createDefaultUiState("custom"), |
| 93 | openApiBaseUrlOptions: editValues.baseUrl | 104 | openApiBaseUrlOptions: editValues.baseUrl |
| 94 | ? [editValues.baseUrl] | 105 | ? [editValues.baseUrl] |
| @@ -97,16 +108,12 @@ export function useAddSourceFormState({ · 2 unmodified lines | |||
| 97 | return fallback ? [fallback] : []; | 108 | return fallback ? [fallback] : []; |
| 98 | })(), | 109 | })(), |
| 99 | authManuallyEdited: Boolean(sourceToEdit.config.auth), | 110 | authManuallyEdited: Boolean(sourceToEdit.config.auth), |
| 100 | authRevision: ui.authRevision + 1, | ||
| 101 | }); | 111 | }); |
| 102 | return; | 112 | return; |
| 103 | } | 113 | } |
| 104 | 114 | ||
| 105 | form.reset(createDefaultFormValues()); | 115 | form.reset(createDefaultFormValues()); |
| 106 | patchUi(setUi, { | 116 | patchUiWithAuthRevision(setUi, createDefaultUiState("catalog")); |
| 107 | ...createDefaultUiState("catalog"), | ||
| 108 | authRevision: ui.authRevision + 1, | ||
| 109 | }); | ||
| 110 | }, [form, open, sourceToEdit]); | 117 | }, [form, open, sourceToEdit]); |
| 111 | 118 | ||
| 112 | const getTakenSourceNames = () => { | 119 | const getTakenSourceNames = () => { |
| @@ -157,11 +164,10 @@ export function useAddSourceFormState({ · 44 unmodified lines | |||
| 157 | endpoint: item.specUrl, | 164 | endpoint: item.specUrl, |
| 158 | baseUrl: defaultBaseUrl, | 165 | baseUrl: defaultBaseUrl, |
| 159 | }); | 166 | }); |
| 160 | patchUi(setUi, { | 167 | patchUiWithAuthRevision(setUi, { |
| 161 | view: "custom", | 168 | view: "custom", |
| 162 | openApiBaseUrlOptions: defaultBaseUrl ? [defaultBaseUrl] : [], | 169 | openApiBaseUrlOptions: defaultBaseUrl ? [defaultBaseUrl] : [], |
| 163 | authManuallyEdited: false, | 170 | authManuallyEdited: false, |
| 164 | authRevision: ui.authRevision + 1, | ||
| 165 | }); | 171 | }); |
| 166 | }; | 172 | }; |
| 167 | 173 | ||
| @@ -180,10 +186,9 @@ export function useAddSourceFormState({ · 12 unmodified lines | |||
| 180 | return; | 186 | return; |
| 181 | } | 187 | } |
| 182 | 188 | ||
| 183 | patchUi(setUi, { | 189 | patchUiWithAuthRevision(setUi, { |
| 184 | openApiBaseUrlOptions: [], | 190 | openApiBaseUrlOptions: [], |
| 185 | authManuallyEdited: false, | 191 | authManuallyEdited: false, |
| 186 | authRevision: ui.authRevision + 1, | ||
| 187 | }); | 192 | }); |
| 188 | form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); | 193 | form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false }); |
| 189 | form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); | 194 | form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false }); |
| @@ -194,22 +199,22 @@ export function useAddSourceFormState({ · 4 unmodified lines | |||
| 194 | form.setValue("basicPassword", "", { shouldDirty: false, shouldTouch: false }); | 199 | form.setValue("basicPassword", "", { shouldDirty: false, shouldTouch: false }); |
| 195 | }; | 200 | }; |
| 196 | 201 | ||
| 197 | const bumpAuthRevision = () => patchUi(setUi, { authRevision: ui.authRevision + 1 }); | 202 | const bumpAuthRevision = () => patchUiWithAuthRevision(setUi); |
| 198 | 203 | ||
| 199 | const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => { | 204 | const handleAuthTypeChange = (authType: Exclude<SourceAuthType, "mixed">) => { |
| 200 | form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true }); | 205 | form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true }); |
| 201 | patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 }); | 206 | patchUiWithAuthRevision(setUi, { authManuallyEdited: true }); |
| 202 | }; | 207 | }; |
| 203 | 208 | ||
| 204 | const handleAuthScopeChange = (authScope: CredentialScope) => { | 209 | const handleAuthScopeChange = (authScope: CredentialScope) => { |
| 205 | form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true }); | 210 | form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true }); |
| 206 | patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 }); | 211 | patchUiWithAuthRevision(setUi, { authManuallyEdited: true }); |
| 207 | }; | 212 | }; |
| 208 | 213 | ||
| 209 | const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => { | 214 | const handleAuthFieldChange = (field: SourceAuthPanelEditableField, value: string) => { |
| 210 | if (field === "apiKeyHeader") { | 215 | if (field === "apiKeyHeader") { |
| 211 | form.setValue("apiKeyHeader", value, { shouldDirty: true, shouldTouch: true }); | 216 | form.setValue("apiKeyHeader", value, { shouldDirty: true, shouldTouch: true }); |
| 212 | patchUi(setUi, { authManuallyEdited: true, authRevision: ui.authRevision + 1 }); | 217 | patchUiWithAuthRevision(setUi, { authManuallyEdited: true }); |
| 213 | return; | 218 | return; |
| 214 | } | 219 | } |
| 215 | 220 | ||
| @@ -1,5 +1,4 @@ | |||
| 1 | import type { Doc } from "../_generated/dataModel"; | 1 | import type { DbCtx, OrganizationId } from "./types"; |
| 2 | import type { AccountId, DbCtx, OrganizationId } from "./types"; | ||
| 3 | 2 | ||
| 4 | export async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) { | 3 | export async function getAccountByWorkosId(ctx: DbCtx, workosUserId: string) { |
| 5 | return await ctx.db | 4 | return await ctx.db |
| @@ -28,14 +27,3 @@ export async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organization · 22 unmodified lines | |||
| 28 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) | 27 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) |
| 29 | .first(); | 28 | .first(); |
| 30 | } | 29 | } |
| 31 | |||
| 32 | export async function getWorkspaceMembershipByWorkspaceAndAccount( | ||
| 33 | ctx: DbCtx, | ||
| 34 | workspaceId: Doc<"workspaces">["_id"], | ||
| 35 | accountId: AccountId, | ||
| 36 | ) { | ||
| 37 | return await ctx.db | ||
| 38 | .query("workspaceMembers") | ||
| 39 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId)) | ||
| 40 | .unique(); | ||
| 41 | } | ||
| @@ -12,7 +12,7 @@ function deriveFallbackUserLabel(workosUserId: string): string { · 11 unmodified lines | |||
| 12 | return `User ${workosUserId.slice(-6)}`; | 12 | return `User ${workosUserId.slice(-6)}`; |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | export function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { | 15 | function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { |
| 16 | for (const key of keys) { | 16 | for (const key of keys) { |
| 17 | const value = identity[key]; | 17 | const value = identity[key]; |
| 18 | if (typeof value === "string" && value.trim().length > 0) { | 18 | if (typeof value === "string" && value.trim().length > 0) { |
| @@ -2,7 +2,7 @@ import type { Doc } from "../_generated/dataModel"; · 1 unmodified line | |||
| 2 | import { asRecord } from "../lib/object"; | 2 | import { asRecord } from "../lib/object"; |
| 3 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; | 3 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; |
| 4 | 4 | ||
| 5 | export function stableStringify(value: unknown): string { | 5 | function stableStringify(value: unknown): string { |
| 6 | if (Array.isArray(value)) { | 6 | if (Array.isArray(value)) { |
| 7 | return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; | 7 | return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; |
| 8 | } | 8 | } |
| @@ -2,7 +2,7 @@ import { internal } from "../_generated/api"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | export type TaskEventName = "task" | "approval"; | 3 | export type TaskEventName = "task" | "approval"; |
| 4 | 4 | ||
| 5 | export interface TaskEventMutationContext { | 5 | interface TaskEventMutationContext { |
| 6 | runMutation: ( | 6 | runMutation: ( |
| 7 | mutation: typeof internal.database.createTaskEvent, | 7 | mutation: typeof internal.database.createTaskEvent, |
| 8 | args: { | 8 | args: { |
| @@ -5,7 +5,7 @@ import type { TaskRecord, TaskStatus } from "../../core/src/types"; · 4 unmodified lines | |||
| 5 | type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; | 5 | type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; |
| 6 | type TaskFinishContext = Pick<ActionCtx, "runMutation"> | Pick<MutationCtx, "runMutation">; | 6 | type TaskFinishContext = Pick<ActionCtx, "runMutation"> | Pick<MutationCtx, "runMutation">; |
| 7 | 7 | ||
| 8 | export interface MarkTaskFinishedInput { | 8 | interface MarkTaskFinishedInput { |
| 9 | taskId: string; | 9 | taskId: string; |
| 10 | status: TerminalTaskStatus; | 10 | status: TerminalTaskStatus; |
| 11 | exitCode?: number; | 11 | exitCode?: number; |
| @@ -1,6 +1,6 @@ | |||
| 1 | import type { TaskStatus } from "../../core/src/types"; | 1 | import type { TaskStatus } from "../../core/src/types"; |
| 2 | 2 | ||
| 3 | export type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; | 3 | type TerminalTaskStatus = Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; |
| 4 | 4 | ||
| 5 | export function isTerminalTaskStatus(status: TaskStatus): status is TerminalTaskStatus { | 5 | export function isTerminalTaskStatus(status: TaskStatus): status is TerminalTaskStatus { |
| 6 | return status === "completed" || status === "failed" || status === "timed_out" || status === "denied"; | 6 | return status === "completed" || status === "failed" || status === "timed_out" || status === "denied"; |
| @@ -1,199 +1,4 @@ | |||
| 1 | import type { ToolDescriptor } from "./types"; | 1 | import type { ToolDescriptor } from "./types"; |
| 2 | import { | ||
| 3 | getSourceFileParseDiagnostics, | ||
| 4 | getTypeScriptModule, | ||
| 5 | indentBlock, | ||
| 6 | propertyNameText, | ||
| 7 | } from "./typechecker-ts-utils"; | ||
| 8 | import { OPENAPI_HELPER_TYPES } from "./openapi-helper-types"; | ||
| 9 | |||
| 10 | function isValidTypeExpression(typeExpression: string): boolean { | ||
| 11 | const ts = getTypeScriptModule(); | ||
| 12 | if (!ts) { | ||
| 13 | return !/[\r\n`]/.test(typeExpression); | ||
| 14 | } | ||
| 15 | |||
| 16 | return (() => { | ||
| 17 | try { | ||
| 18 | const sourceFile = ts.createSourceFile( | ||
| 19 | "_type_expr_check.ts", | ||
| 20 | `type __T = ${typeExpression};`, | ||
| 21 | ts.ScriptTarget.ESNext, | ||
| 22 | true, | ||
| 23 | ts.ScriptKind.TS, | ||
| 24 | ); | ||
| 25 | return getSourceFileParseDiagnostics(sourceFile).length === 0; | ||
| 26 | } catch { | ||
| 27 | return false; | ||
| 28 | } | ||
| 29 | })(); | ||
| 30 | } | ||
| 31 | |||
| 32 | function safeTypeExpression(raw: string | undefined, fallback: string): string { | ||
| 33 | const typeExpression = raw?.trim(); | ||
| 34 | if (!typeExpression) return fallback; | ||
| 35 | return isValidTypeExpression(typeExpression) ? typeExpression : fallback; | ||
| 36 | } | ||
| 37 | |||
| 38 | /** | ||
| 39 | * Build a minimal OpenAPI .d.ts containing only selected `operations` members. | ||
| 40 | * Returns null when slicing is not possible and callers should use full .d.ts. | ||
| 41 | */ | ||
| 42 | export function sliceOpenApiOperationsDts( | ||
| 43 | dts: string, | ||
| 44 | operationIds: Iterable<string>, | ||
| 45 | ): string | null { | ||
| 46 | const ts = getTypeScriptModule(); | ||
| 47 | if (!ts) return null; | ||
| 48 | |||
| 49 | const wanted = new Set([...operationIds].filter((value) => value.trim().length > 0)); | ||
| 50 | if (wanted.size === 0) return null; | ||
| 51 | |||
| 52 | const sourceFile = ts.createSourceFile( | ||
| 53 | "openapi-source.d.ts", | ||
| 54 | dts, | ||
| 55 | ts.ScriptTarget.ESNext, | ||
| 56 | true, | ||
| 57 | ts.ScriptKind.TS, | ||
| 58 | ); | ||
| 59 | |||
| 60 | const operationsInterface = sourceFile.statements.find((statement) => | ||
| 61 | ts.isInterfaceDeclaration(statement) | ||
| 62 | && statement.name.text === "operations", | ||
| 63 | ); | ||
| 64 | |||
| 65 | if (!operationsInterface || !ts.isInterfaceDeclaration(operationsInterface)) { | ||
| 66 | return null; | ||
| 67 | } | ||
| 68 | |||
| 69 | const selectedMembers: string[] = []; | ||
| 70 | for (const member of operationsInterface.members) { | ||
| 71 | if (!ts.isPropertySignature(member) || !member.name) continue; | ||
| 72 | const key = propertyNameText(member.name, ts); | ||
| 73 | if (!key || !wanted.has(key)) continue; | ||
| 74 | |||
| 75 | const raw = dts.slice(member.getFullStart(), member.end).trim(); | ||
| 76 | if (raw.length > 0) { | ||
| 77 | selectedMembers.push(raw); | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | if (selectedMembers.length === 0) return null; | ||
| 82 | |||
| 83 | const body = selectedMembers.map((member) => indentBlock(member)).join("\n"); | ||
| 84 | return `export interface operations {\n${body}\n}`; | ||
| 85 | } | ||
| 86 | |||
| 87 | function stripExportKeywordsForTypechecker(dts: string): string { | ||
| 88 | return dts.replace(/\bexport\s+/g, "").trim(); | ||
| 89 | } | ||
| 90 | |||
| 91 | export interface GenerateToolDeclarationOptions { | ||
| 92 | sourceDtsBySource?: Record<string, string>; | ||
| 93 | } | ||
| 94 | |||
| 95 | interface ToolTreeNode { | ||
| 96 | children: Map<string, ToolTreeNode>; | ||
| 97 | tool?: ToolDescriptor; | ||
| 98 | } | ||
| 99 | |||
| 100 | function createToolTree(tools: ToolDescriptor[]): ToolTreeNode { | ||
| 101 | const root: ToolTreeNode = { children: new Map() }; | ||
| 102 | |||
| 103 | for (const tool of tools) { | ||
| 104 | const segments = tool.path.split("."); | ||
| 105 | let node = root; | ||
| 106 | for (let i = 0; i < segments.length; i += 1) { | ||
| 107 | const segment = segments[i]; | ||
| 108 | if (!node.children.has(segment)) { | ||
| 109 | node.children.set(segment, { children: new Map() }); | ||
| 110 | } | ||
| 111 | node = node.children.get(segment)!; | ||
| 112 | if (i === segments.length - 1) { | ||
| 113 | node.tool = tool; | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | return root; | ||
| 119 | } | ||
| 120 | |||
| 121 | function renderToolCallSignature( | ||
| 122 | key: string, | ||
| 123 | tool: ToolDescriptor, | ||
| 124 | dtsSources: Set<string>, | ||
| 125 | pad: string, | ||
| 126 | ): string { | ||
| 127 | if (tool.operationId && tool.source && dtsSources.has(tool.source)) { | ||
| 128 | const opKey = JSON.stringify(tool.operationId); | ||
| 129 | const openApiInputType = `ToolInput<operations[${opKey}]>`; | ||
| 130 | const openApiOutputType = `ToolOutput<operations[${opKey}]>`; | ||
| 131 | return `${pad}${key}(input: ${openApiInputType}): Promise<${openApiOutputType}>;`; | ||
| 132 | } | ||
| 133 | |||
| 134 | const strictArgsType = tool.strictArgsType?.trim(); | ||
| 135 | const strictReturnsType = tool.strictReturnsType?.trim(); | ||
| 136 | const effectiveArgs = strictArgsType || tool.argsType; | ||
| 137 | const effectiveReturns = strictReturnsType || tool.returnsType; | ||
| 138 | const hasArgsType = Boolean(effectiveArgs?.trim()); | ||
| 139 | const args = safeTypeExpression(effectiveArgs, "Record<string, unknown>"); | ||
| 140 | const returns = safeTypeExpression(effectiveReturns, "unknown"); | ||
| 141 | const inputParam = !hasArgsType || args === "{}" | ||
| 142 | ? `input?: ${args}` | ||
| 143 | : `input: ${args}`; | ||
| 144 | |||
| 145 | return `${pad}${key}(${inputParam}): Promise<${returns}>;`; | ||
| 146 | } | ||
| 147 | |||
| 148 | function renderToolTree( | ||
| 149 | node: ToolTreeNode, | ||
| 150 | indent: number, | ||
| 151 | dtsSources: Set<string>, | ||
| 152 | ): string { | ||
| 153 | const pad = " ".repeat(indent); | ||
| 154 | const lines: string[] = []; | ||
| 155 | |||
| 156 | for (const [key, child] of node.children) { | ||
| 157 | if (child.tool) { | ||
| 158 | lines.push(renderToolCallSignature(key, child.tool, dtsSources, pad)); | ||
| 159 | continue; | ||
| 160 | } | ||
| 161 | |||
| 162 | lines.push(`${pad}${key}: {`); | ||
| 163 | lines.push(renderToolTree(child, indent + 1, dtsSources)); | ||
| 164 | lines.push(`${pad}};`); | ||
| 165 | } | ||
| 166 | |||
| 167 | return lines.join("\n"); | ||
| 168 | } | ||
| 169 | |||
| 170 | /** | ||
| 171 | * Build a `declare const tools: { ... }` block from flat tool descriptors. | ||
| 172 | */ | ||
| 173 | export function generateToolDeclarations( | ||
| 174 | tools: ToolDescriptor[], | ||
| 175 | options?: GenerateToolDeclarationOptions, | ||
| 176 | ): string { | ||
| 177 | const sourceDtsBySource = options?.sourceDtsBySource ?? {}; | ||
| 178 | const dtsSources = new Set(Object.keys(sourceDtsBySource)); | ||
| 179 | const root = createToolTree(tools); | ||
| 180 | |||
| 181 | const parts: string[] = []; | ||
| 182 | |||
| 183 | const dtsEntries = Object.entries(sourceDtsBySource) | ||
| 184 | .filter(([, dts]) => typeof dts === "string" && dts.trim().length > 0) | ||
| 185 | .sort(([a], [b]) => a.localeCompare(b)); | ||
| 186 | |||
| 187 | if (dtsEntries.length > 0) { | ||
| 188 | parts.push(OPENAPI_HELPER_TYPES); | ||
| 189 | for (const [sourceKey, dts] of dtsEntries) { | ||
| 190 | parts.push(`// OpenAPI types from ${sourceKey}\n${stripExportKeywordsForTypechecker(dts)}`); | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | parts.push(`declare const tools: {\n${renderToolTree(root, 1, dtsSources)}\n};`); | ||
| 195 | return parts.join("\n"); | ||
| 196 | } | ||
| 197 | 2 | ||
| 198 | /** | 3 | /** |
| 199 | * Generate the tool inventory text for the MCP run_code description. | 4 | * Generate the tool inventory text for the MCP run_code description. |
| @@ -4,46 +4,3 @@ export interface LiveTaskEvent { · 3 unmodified lines | |||
| 4 | payload: unknown; | 4 | payload: unknown; |
| 5 | createdAt: number; | 5 | createdAt: number; |
| 6 | } | 6 | } |
| 7 | |||
| 8 | type Subscriber = (event: LiveTaskEvent) => void; | ||
| 9 | |||
| 10 | export class TaskEventHub { | ||
| 11 | private readonly subscribers = new Map<string, Set<Subscriber>>(); | ||
| 12 | |||
| 13 | publish(taskId: string, event: LiveTaskEvent): void { | ||
| 14 | const listeners = this.subscribers.get(taskId); | ||
| 15 | if (!listeners || listeners.size === 0) { | ||
| 16 | return; | ||
| 17 | } | ||
| 18 | |||
| 19 | for (const listener of listeners) { | ||
| 20 | try { | ||
| 21 | listener(event); | ||
| 22 | } catch { | ||
| 23 | listeners.delete(listener); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | if (listeners.size === 0) { | ||
| 28 | this.subscribers.delete(taskId); | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | subscribe(taskId: string, listener: Subscriber): () => void { | ||
| 33 | const listeners = this.subscribers.get(taskId) ?? new Set<Subscriber>(); | ||
| 34 | listeners.add(listener); | ||
| 35 | this.subscribers.set(taskId, listeners); | ||
| 36 | |||
| 37 | return () => { | ||
| 38 | const active = this.subscribers.get(taskId); | ||
| 39 | if (!active) { | ||
| 40 | return; | ||
| 41 | } | ||
| 42 | |||
| 43 | active.delete(listener); | ||
| 44 | if (active.size === 0) { | ||
| 45 | this.subscribers.delete(taskId); | ||
| 46 | } | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | } | ||
| @@ -22,7 +22,7 @@ function isTruthyEnvValue(value: string | undefined): boolean { · 21 unmodified lines | |||
| 22 | return TRUTHY_ENV_VALUES.has(value.trim().toLowerCase()); | 22 | return TRUTHY_ENV_VALUES.has(value.trim().toLowerCase()); |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | export function isCloudflareDynamicWorkerOnlyMode(): boolean { | 25 | function isCloudflareDynamicWorkerOnlyMode(): boolean { |
| 26 | return isTruthyEnvValue(process.env[CLOUDFLARE_DYNAMIC_WORKER_ONLY_ENV_KEY]); | 26 | return isTruthyEnvValue(process.env[CLOUDFLARE_DYNAMIC_WORKER_ONLY_ENV_KEY]); |
| 27 | } | 27 | } |
| 28 | 28 | ||
| @@ -6,7 +6,7 @@ import { Result, TaggedError } from "better-result"; · 5 unmodified lines | |||
| 6 | // Errors | 6 | // Errors |
| 7 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 8 | 8 | ||
| 9 | export class TranspileError extends TaggedError("TranspileError")<{ | 9 | class TranspileError extends TaggedError("TranspileError")<{ |
| 10 | message: string; | 10 | message: string; |
| 11 | }>() {} | 11 | }>() {} |
| 12 | 12 | ||
| @@ -55,7 +55,7 @@ function simplifyNamespaceSegment(segment: string): string { · 54 unmodified lines | |||
| 55 | return collapsed.join("_"); | 55 | return collapsed.join("_"); |
| 56 | } | 56 | } |
| 57 | 57 | ||
| 58 | export function preferredToolPath(path: string): string { | 58 | function preferredToolPath(path: string): string { |
| 59 | const segments = path.split(".").filter(Boolean); | 59 | const segments = path.split(".").filter(Boolean); |
| 60 | if (segments.length === 0) return path; | 60 | if (segments.length === 0) return path; |
| 61 | 61 | ||
| @@ -13,7 +13,7 @@ export interface GraphqlExecutionEnvelope { · 12 unmodified lines | |||
| 13 | errors: unknown[]; | 13 | errors: unknown[]; |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | export function isMcpReconnectableError(error: unknown): boolean { | 16 | function isMcpReconnectableError(error: unknown): boolean { |
| 17 | const message = error instanceof Error ? error.message : String(error); | 17 | const message = error instanceof Error ? error.message : String(error); |
| 18 | return /(socket|closed|ECONNRESET|fetch failed)/i.test(message); | 18 | return /(socket|closed|ECONNRESET|fetch failed)/i.test(message); |
| 19 | } | 19 | } |
| @@ -106,14 +106,14 @@ export async function executeOpenApiRequest( · 86 unmodified lines | |||
| 106 | return await response.text(); | 106 | return await response.text(); |
| 107 | } | 107 | } |
| 108 | 108 | ||
| 109 | export function hasGraphqlData(data: unknown): boolean { | 109 | function hasGraphqlData(data: unknown): boolean { |
| 110 | if (data === null || data === undefined) return false; | 110 | if (data === null || data === undefined) return false; |
| 111 | if (Array.isArray(data)) return data.length > 0; | 111 | if (Array.isArray(data)) return data.length > 0; |
| 112 | if (typeof data === "object") return Object.keys(data as Record<string, unknown>).length > 0; | 112 | if (typeof data === "object") return Object.keys(data as Record<string, unknown>).length > 0; |
| 113 | return true; | 113 | return true; |
| 114 | } | 114 | } |
| 115 | 115 | ||
| 116 | export function normalizeGraphqlEnvelope(result: { data?: unknown; errors?: unknown[] }): GraphqlExecutionEnvelope { | 116 | function normalizeGraphqlEnvelope(result: { data?: unknown; errors?: unknown[] }): GraphqlExecutionEnvelope { |
| 117 | return { | 117 | return { |
| 118 | data: result.data ?? null, | 118 | data: result.data ?? null, |
| 119 | errors: Array.isArray(result.errors) ? result.errors : [], | 119 | errors: Array.isArray(result.errors) ? result.errors : [], |
| @@ -21,7 +21,7 @@ import type { SerializedTool } from "../tool-source-serialization"; · 20 unmodified lines | |||
| 21 | const POSTMAN_SPEC_PREFIX = "postman:"; | 21 | const POSTMAN_SPEC_PREFIX = "postman:"; |
| 22 | const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy"; | 22 | const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy"; |
| 23 | 23 | ||
| 24 | export function parsePostmanCollectionUid(spec: string): string | null { | 24 | function parsePostmanCollectionUid(spec: string): string | null { |
| 25 | if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) { | 25 | if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) { |
| 26 | return null; | 26 | return null; |
| 27 | } | 27 | } |
| @@ -24,52 +24,6 @@ export { prepareOpenApiSpec } from "./openapi-prepare"; · 23 unmodified lines | |||
| 24 | export { parseGraphqlOperationPaths } from "./graphql-operation-paths"; | 24 | export { parseGraphqlOperationPaths } from "./graphql-operation-paths"; |
| 25 | export { rehydrateTools, serializeTools, type SerializedTool } from "./tool-source-serialization"; | 25 | export { rehydrateTools, serializeTools, type SerializedTool } from "./tool-source-serialization"; |
| 26 | export { buildOpenApiToolsFromPrepared } from "./openapi-tool-builder"; | 26 | export { buildOpenApiToolsFromPrepared } from "./openapi-tool-builder"; |
| 27 | export { parsePostmanCollectionUid } from "./tool-source-loaders/openapi-loader"; | ||
| 28 | |||
| 29 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 30 | return typeof value === "object" && value !== null; | ||
| 31 | } | ||
| 32 | |||
| 33 | function isNonEmptyString(value: unknown): value is string { | ||
| 34 | return typeof value === "string" && value.trim().length > 0; | ||
| 35 | } | ||
| 36 | |||
| 37 | function isExternalToolSourceType(value: unknown): value is ExternalToolSourceConfig["type"] { | ||
| 38 | return value === "mcp" || value === "openapi" || value === "graphql"; | ||
| 39 | } | ||
| 40 | |||
| 41 | export function parseToolSourcesFromEnv(raw: string | undefined): ExternalToolSourceConfig[] { | ||
| 42 | if (!raw || raw.trim().length === 0) { | ||
| 43 | return []; | ||
| 44 | } | ||
| 45 | |||
| 46 | let parsed: unknown; | ||
| 47 | try { | ||
| 48 | parsed = JSON.parse(raw) as unknown; | ||
| 49 | } catch (error) { | ||
| 50 | const message = error instanceof Error ? error.message : String(error); | ||
| 51 | throw new Error(`EXECUTOR_TOOL_SOURCES must be valid JSON: ${message}`); | ||
| 52 | } | ||
| 53 | |||
| 54 | if (!Array.isArray(parsed)) { | ||
| 55 | throw new Error("EXECUTOR_TOOL_SOURCES must be a JSON array"); | ||
| 56 | } | ||
| 57 | |||
| 58 | for (let i = 0; i < parsed.length; i += 1) { | ||
| 59 | const entry = parsed[i]; | ||
| 60 | if (!isRecord(entry)) { | ||
| 61 | throw new Error(`EXECUTOR_TOOL_SOURCES[${i}] must be an object`); | ||
| 62 | } | ||
| 63 | if (!isExternalToolSourceType(entry.type)) { | ||
| 64 | throw new Error(`EXECUTOR_TOOL_SOURCES[${i}].type must be one of: mcp, openapi, graphql`); | ||
| 65 | } | ||
| 66 | if (!isNonEmptyString(entry.name)) { | ||
| 67 | throw new Error(`EXECUTOR_TOOL_SOURCES[${i}].name must be a non-empty string`); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | return parsed as ExternalToolSourceConfig[]; | ||
| 72 | } | ||
| 73 | 27 | ||
| 74 | export interface CompiledToolSourceArtifact { | 28 | export interface CompiledToolSourceArtifact { |
| 75 | version: "v1"; | 29 | version: "v1"; |
| @@ -188,7 +188,7 @@ export function extractTopLevelTypeKeys(typeHint: string): string[] { · 187 unmodified lines | |||
| 188 | return keys; | 188 | return keys; |
| 189 | } | 189 | } |
| 190 | 190 | ||
| 191 | export function extractTopLevelTypeKeysFromExpression(typeHint: string): string[] { | 191 | function extractTopLevelTypeKeysFromExpression(typeHint: string): string[] { |
| 192 | const direct = extractTopLevelTypeKeys(typeHint); | 192 | const direct = extractTopLevelTypeKeys(typeHint); |
| 193 | if (direct.length > 0) return direct; | 193 | if (direct.length > 0) return direct; |
| 194 | 194 | ||
| @@ -1,55 +0,0 @@ | |||
| 1 | import { Result } from "better-result"; | ||
| 2 | |||
| 3 | export function getTypeScriptModule(): typeof import("typescript") | null { | ||
| 4 | const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | ||
| 5 | return loaded.isOk() ? loaded.value : null; | ||
| 6 | } | ||
| 7 | |||
| 8 | export function getSourceFileParseDiagnostics( | ||
| 9 | sourceFile: import("typescript").SourceFile, | ||
| 10 | ): import("typescript").Diagnostic[] { | ||
| 11 | return ( | ||
| 12 | sourceFile as { parseDiagnostics?: import("typescript").Diagnostic[] } | ||
| 13 | ).parseDiagnostics ?? []; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function unwrapExpression( | ||
| 17 | expression: import("typescript").Expression, | ||
| 18 | ts: typeof import("typescript"), | ||
| 19 | ): import("typescript").Expression { | ||
| 20 | let current = expression; | ||
| 21 | while ( | ||
| 22 | ts.isParenthesizedExpression(current) | ||
| 23 | || ts.isAsExpression(current) | ||
| 24 | || ts.isTypeAssertionExpression(current) | ||
| 25 | || ts.isNonNullExpression(current) | ||
| 26 | || ts.isPartiallyEmittedExpression(current) | ||
| 27 | || ts.isSatisfiesExpression(current) | ||
| 28 | ) { | ||
| 29 | current = current.expression; | ||
| 30 | } | ||
| 31 | return current; | ||
| 32 | } | ||
| 33 | |||
| 34 | export function propertyNameText( | ||
| 35 | name: import("typescript").PropertyName, | ||
| 36 | ts: typeof import("typescript"), | ||
| 37 | ): string | null { | ||
| 38 | if (ts.isIdentifier(name)) return name.text; | ||
| 39 | if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text; | ||
| 40 | if (ts.isNumericLiteral(name)) return name.text; | ||
| 41 | if (ts.isComputedPropertyName(name)) { | ||
| 42 | const expression = unwrapExpression(name.expression, ts); | ||
| 43 | if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression) || ts.isNumericLiteral(expression)) { | ||
| 44 | return expression.text; | ||
| 45 | } | ||
| 46 | } | ||
| 47 | return null; | ||
| 48 | } | ||
| 49 | |||
| 50 | export function indentBlock(text: string, indent = " "): string { | ||
| 51 | return text | ||
| 52 | .split("\n") | ||
| 53 | .map((line) => (line.trim().length === 0 ? "" : `${indent}${line}`)) | ||
| 54 | .join("\n"); | ||
| 55 | } | ||