From 224fe277b2621d8e9b90be175ef783541e9b6aa4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:57:41 +0000 Subject: [PATCH 1/4] Initial plan From 4c1e1b265245fe7b35db9a895cb1958c8af9df4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:05:00 +0000 Subject: [PATCH 2/4] fix: address critical and major PR review comments - Fix path traversal by using fetchRegistryFileContent from api.ts - Fix file.path undefined error with explicit validation - Fix results structure to match InstallResults type with component field - Fix error handling to properly detect error instances - Validate required IDs for trigger install URLs - Fix apiGroupId type to string | number for consistency - Fix getApiGroupByName to return created group - Guard against empty registry items in promptForComponents - Fix Jest config to preserve base ignore patterns - Update AGENTS.md to reflect test files exist - Fix treat empty inline content as valid with null check - Fix registryItem.id reference error Co-authored-by: MihalyToth20 <34799518+MihalyToth20@users.noreply.github.com> --- AGENTS.md | 2 +- packages/cli/jest.config.js | 5 +- .../registry/implementation/registry.ts | 4 +- .../utils/feature-focused/registry/general.ts | 11 ++- packages/core/src/features/registry/api.ts | 2 +- .../src/features/registry/install-to-xano.ts | 84 ++++++++++++++----- packages/types/src/index.ts | 2 +- 7 files changed, 82 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d85a712..43518f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,7 @@ This project provides a suite of tools to improve developer experience with Xano ### Testing - **Test framework**: Jest with ts-jest transformer -- **Test files**: `.test.ts` extension (currently no test files exist) +- **Test files**: `.test.ts` or `.spec.ts` extension - **Test configuration**: JSON-based config files for API testing - **Test environment**: Node.js environment - **Coverage**: Use `jest-html-reporter` for HTML coverage reports diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index 0384203..1bf0f42 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -1,5 +1,8 @@ import config from '../../jest.config.js'; export default { ...config, - testPathIgnorePatterns: ['src/commands/test/implementation/'], + testPathIgnorePatterns: [ + ...(config.testPathIgnorePatterns ?? []), + 'src/commands/test/implementation/', + ], }; diff --git a/packages/cli/src/commands/registry/implementation/registry.ts b/packages/cli/src/commands/registry/implementation/registry.ts index ba0b660..4aed227 100644 --- a/packages/cli/src/commands/registry/implementation/registry.ts +++ b/packages/cli/src/commands/registry/implementation/registry.ts @@ -59,7 +59,9 @@ async function addToXano({ for (const file of registryItem.files) { if (file.type === 'registry:query') { if (!file.apiGroupName) { - throw new Error(`Missing apiGroupName for file ${file.path || 'unnamed'} in registry item ${registryItem.name || registryItem.id}`); + throw new Error( + `Missing apiGroupName for file ${file.path || 'unnamed'} in registry item ${componentName}`, + ); } const apiGroup = await getApiGroupByName( file.apiGroupName, diff --git a/packages/cli/src/utils/feature-focused/registry/general.ts b/packages/cli/src/utils/feature-focused/registry/general.ts index 4e38096..a5c333a 100644 --- a/packages/cli/src/utils/feature-focused/registry/general.ts +++ b/packages/cli/src/utils/feature-focused/registry/general.ts @@ -33,9 +33,15 @@ async function promptForComponents(core, registryUrl) { try { const registry = await core.getRegistryIndex(registryUrl); - const options = registry.items.map((item) => ({ + const items = registry?.items ?? []; + if (!items.length) { + console.error('No components available in registry index.'); + return []; + } + + const options = items.map((item) => ({ value: item.name, - label: `${item.name} - ${item.description}`, + label: item.description ? `${item.name} - ${item.description}` : item.name, })); const selected = await multiselect({ @@ -89,6 +95,7 @@ async function getApiGroupByName( }, }); } + return selectedGroup; } export { sortFilesByType, promptForComponents, getApiGroupByName }; diff --git a/packages/core/src/features/registry/api.ts b/packages/core/src/features/registry/api.ts index b058e9a..3724d38 100644 --- a/packages/core/src/features/registry/api.ts +++ b/packages/core/src/features/registry/api.ts @@ -72,7 +72,7 @@ async function getRegistryItem(name, registryUrl) { * Get registry item content, prioritizing inline content over file paths. */ async function fetchRegistryFileContent(item, filePath, registryUrl) { - if (item.content) { + if (item.content != null) { return item.content; } const normalized = validateRegistryPath(filePath); diff --git a/packages/core/src/features/registry/install-to-xano.ts b/packages/core/src/features/registry/install-to-xano.ts index 9b449fc..fc48d51 100644 --- a/packages/core/src/features/registry/install-to-xano.ts +++ b/packages/core/src/features/registry/install-to-xano.ts @@ -1,6 +1,13 @@ -import { BranchConfig, InstanceConfig, RegistryItemFile, WorkspaceConfig } from '@repo/types'; +import { + BranchConfig, + InstanceConfig, + InstallResults, + RegistryItemFile, + WorkspaceConfig, +} from '@repo/types'; import type { Caly } from '../..'; import { sortFilesByType } from './general'; +import { fetchRegistryFileContent } from './api'; interface InstallParams { instanceConfig: InstanceConfig; @@ -29,10 +36,30 @@ const REGISTRY_MAP: Record = { // Complex/Nested paths 'registry:query': (p) => `apigroup/${p.apiGroupId}/query`, - 'registry:table/trigger': (p) => `table/${p.file?.tableId}/trigger`, - 'registry:mcp/trigger': (p) => `mcp/${p.file?.mcpId}/trigger`, - 'registry:agent/trigger': (p) => `agent/${p.file?.agentId}/trigger`, - 'registry:realtime/trigger': (p) => `realtime/${p.file?.realtimeId}/trigger`, + 'registry:table/trigger': (p) => { + if (!p.file?.tableId) { + throw new Error('tableId required for table trigger installation'); + } + return `table/${p.file.tableId}/trigger`; + }, + 'registry:mcp/trigger': (p) => { + if (!p.file?.mcpId) { + throw new Error('mcpId required for MCP trigger installation'); + } + return `mcp/${p.file.mcpId}/trigger`; + }, + 'registry:agent/trigger': (p) => { + if (!p.file?.agentId) { + throw new Error('agentId required for agent trigger installation'); + } + return `agent/${p.file.agentId}/trigger`; + }, + 'registry:realtime/trigger': (p) => { + if (!p.file?.realtimeId) { + throw new Error('realtimeId required for realtime trigger installation'); + } + return `realtime/${p.file.realtimeId}/trigger`; + }, }; function resolveInstallUrl(type: string, params: InstallParams) { @@ -66,11 +93,11 @@ async function installRegistryItemToXano( throw new Error('instanceConfig is required for registry installation'); } - const results: { - installed: Array<{ file: string; response: any }>; - failed: Array<{ file: string; error: string }>; - skipped: Array<{ file: string; reason: string }>; - } = { installed: [], failed: [], skipped: [] }; + const results: InstallResults = { + installed: [], + failed: [], + skipped: [], + }; // Sort files let filesToInstall = sortFilesByType(item.files || []); @@ -87,15 +114,13 @@ async function installRegistryItemToXano( try { // Get content: use inline content if present, else fetch from file path let content; - if (file.content) { + if (file.content != null) { content = file.content; + } else if (!file.path) { + throw new Error(`File entry has neither content nor path: ${JSON.stringify(file)}`); } else { - const normalized = file.path.replace(/^\/+/, ''); - const url = `${registryUrl}/${normalized}`; - const res = await fetch(url); - if (!res.ok) - throw new Error(`Failed to fetch file content: ${file.path} (${res.status})`); - content = await res.text(); + // Use the safe fetchRegistryFileContent function from api.ts + content = await fetchRegistryFileContent(file, file.path, registryUrl); } // Determine install URL @@ -132,7 +157,11 @@ async function installRegistryItemToXano( if (response.ok) { const body = await response.json(); - results.installed.push({ file: file.path || '', response: body }); + results.installed.push({ + component: item.name, + file: file.path || '', + response: body, + }); } else { // Try to parse the error response to detect "already exists" / duplicate cases let errorMessage = `HTTP ${response.status}`; @@ -160,13 +189,26 @@ async function installRegistryItemToXano( } if (isSkipped) { - results.skipped.push({ file: file.path || '', reason: errorMessage }); + results.skipped.push({ + component: item.name, + file: file.path || '', + error: errorMessage, + }); } else { - results.failed.push({ file: file.path || '', error: errorMessage }); + results.failed.push({ + component: item.name, + file: file.path || '', + error: errorMessage, + }); } } } catch (error) { - results.failed.push({ file: file.path || '', error: error.message }); + const message = error instanceof Error ? error.message : String(error); + results.failed.push({ + component: item.name, + file: file.path || '', + error: message, + }); } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7aced18..5daba13 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -491,7 +491,7 @@ export interface RegistryItemFile { content?: string; type: RegistryItemType; apiGroupName?: string; - apiGroupId?: string; + apiGroupId?: string | number; meta?: Record; } From ac2be8b2e5e2b7108e41606db42aef9fb365bdc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:07:04 +0000 Subject: [PATCH 3/4] fix: align registry URL mappings with CLI and fix test - Update registry URL map to match CLI paths (workflow_test, mcp_server, realtime/channel, branch params) - Fix test to use 'error' field instead of 'reason' for skipped results - Add Jest config to ignore dist directory in core tests Co-authored-by: MihalyToth20 <34799518+MihalyToth20@users.noreply.github.com> --- packages/core/jest.config.js | 8 ++++- .../__tests__/install-to-xano.spec.ts | 2 +- .../src/features/registry/install-to-xano.ts | 32 +++++++++---------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index 8029e66..8b18064 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -1,2 +1,8 @@ import config from '../../jest.config.js'; -export default config; +export default { + ...config, + testPathIgnorePatterns: [ + ...(config.testPathIgnorePatterns ?? []), + '/dist/', + ], +}; diff --git a/packages/core/src/features/registry/__tests__/install-to-xano.spec.ts b/packages/core/src/features/registry/__tests__/install-to-xano.spec.ts index 4f941a3..e9d5214 100644 --- a/packages/core/src/features/registry/__tests__/install-to-xano.spec.ts +++ b/packages/core/src/features/registry/__tests__/install-to-xano.spec.ts @@ -124,7 +124,7 @@ describe('installRegistryItemToXano', () => { ); expect(results.skipped).toHaveLength(1); - expect(results.skipped[0].reason).toContain('already exists'); + expect(results.skipped[0].error).toContain('already exists'); expect(results.failed).toHaveLength(0); }); diff --git a/packages/core/src/features/registry/install-to-xano.ts b/packages/core/src/features/registry/install-to-xano.ts index fc48d51..1e720a9 100644 --- a/packages/core/src/features/registry/install-to-xano.ts +++ b/packages/core/src/features/registry/install-to-xano.ts @@ -21,44 +21,44 @@ type UrlResolver = (params: InstallParams) => string; const REGISTRY_MAP: Record = { // Simple static-like paths - 'registry:function': (p) => `function`, + 'registry:function': (p) => `function?branch=${p.branchConfig.label}`, 'registry:table': (p) => `table`, - 'registry:addon': (p) => `addon`, - 'registry:apigroup': (p) => `apigroup`, - 'registry:middleware': (p) => `middleware`, - 'registry:task': (p) => `task`, - 'registry:tool': (p) => `tool`, - 'registry:mcp': (p) => `mcp`, - 'registry:agent': (p) => `agent`, - 'registry:realtime': (p) => `realtime`, - 'registry:test': (p) => `test`, - 'registry:workspace/trigger': (p) => `trigger`, + 'registry:addon': (p) => `addon?branch=${p.branchConfig.label}`, + 'registry:apigroup': (p) => `apigroup?branch=${p.branchConfig.label}`, + 'registry:middleware': (p) => `middleware?branch=${p.branchConfig.label}`, + 'registry:task': (p) => `task?branch=${p.branchConfig.label}`, + 'registry:tool': (p) => `tool?branch=${p.branchConfig.label}`, + 'registry:mcp': (p) => `mcp_server?branch=${p.branchConfig.label}`, + 'registry:agent': (p) => `agent?branch=${p.branchConfig.label}`, + 'registry:realtime': (p) => `realtime/channel?branch=${p.branchConfig.label}`, + 'registry:test': (p) => `workflow_test?branch=${p.branchConfig.label}`, + 'registry:workspace/trigger': (p) => `trigger?branch=${p.branchConfig.label}`, // Complex/Nested paths - 'registry:query': (p) => `apigroup/${p.apiGroupId}/query`, + 'registry:query': (p) => `apigroup/${p.apiGroupId}/api?branch=${p.branchConfig.label}`, 'registry:table/trigger': (p) => { if (!p.file?.tableId) { throw new Error('tableId required for table trigger installation'); } - return `table/${p.file.tableId}/trigger`; + return `table/${p.file.tableId}/trigger?branch=${p.branchConfig.label}`; }, 'registry:mcp/trigger': (p) => { if (!p.file?.mcpId) { throw new Error('mcpId required for MCP trigger installation'); } - return `mcp/${p.file.mcpId}/trigger`; + return `mcp_server/${p.file.mcpId}/trigger?branch=${p.branchConfig.label}`; }, 'registry:agent/trigger': (p) => { if (!p.file?.agentId) { throw new Error('agentId required for agent trigger installation'); } - return `agent/${p.file.agentId}/trigger`; + return `agent/${p.file.agentId}/trigger?branch=${p.branchConfig.label}`; }, 'registry:realtime/trigger': (p) => { if (!p.file?.realtimeId) { throw new Error('realtimeId required for realtime trigger installation'); } - return `realtime/${p.file.realtimeId}/trigger`; + return `realtime/channel/${p.file.realtimeId}/trigger?branch=${p.branchConfig.label}`; }, }; From 52857b38134d13e25899da8bf6b3b9d4fb6ac275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Sun, 8 Feb 2026 04:24:28 +0100 Subject: [PATCH 4/4] fix: codegen, name sanitization --- .../generate/implementation/codegen.ts | 8 +- packages/cli/src/commands/generate/index.ts | 2 +- .../features/code-gen/open-api-generator.ts | 76 +++--- .../src/features/registry/install-to-xano.ts | 258 +++++++++--------- packages/types/src/index.ts | 2 +- packages/utils/src/methods/sanitize.ts | 19 +- 6 files changed, 192 insertions(+), 173 deletions(-) diff --git a/packages/cli/src/commands/generate/implementation/codegen.ts b/packages/cli/src/commands/generate/implementation/codegen.ts index 0153026..1c638e9 100644 --- a/packages/cli/src/commands/generate/implementation/codegen.ts +++ b/packages/cli/src/commands/generate/implementation/codegen.ts @@ -98,10 +98,10 @@ async function generateCodeFromOas({ }); s.stop(`Code generated for group "${group.name}" → ${outputPath}/${generator}`); printOutputDir(printOutput, outputPath); - } catch (err) { - s.stop(); - log.error(err.message); - } + } catch (err: any) { + s.stop(); + log.error(err?.message ?? String(err)); + } } const endTime: Date = new Date(); diff --git a/packages/cli/src/commands/generate/index.ts b/packages/cli/src/commands/generate/index.ts index 4bde6c3..dde0304 100644 --- a/packages/cli/src/commands/generate/index.ts +++ b/packages/cli/src/commands/generate/index.ts @@ -44,7 +44,7 @@ function registerGenerateCommands(program, core) { 'Additional arguments to pass to the generator. For options for each generator see https://openapi-generator.tech/docs/usage#generate this also accepts Orval additional arguments e.g. --mock etc. See Orval docs as well: https://orval.dev/reference/configuration/full-example' ) .action( - withErrorHandler(async (opts, passthroughArgs) => { + withErrorHandler(async (passthroughArgs, opts) => { const stack: { generator: string; args: string[] } = { generator: opts.generator || 'typescript-fetch', args: passthroughArgs || [], diff --git a/packages/cli/src/features/code-gen/open-api-generator.ts b/packages/cli/src/features/code-gen/open-api-generator.ts index 34ccb30..b3122e0 100644 --- a/packages/cli/src/features/code-gen/open-api-generator.ts +++ b/packages/cli/src/features/code-gen/open-api-generator.ts @@ -56,42 +56,52 @@ async function runOpenApiGenerator({ const args = buildGeneratorArgs({ generator, inputPath, outputPath, additionalArgs }); const { logStream, logPath } = await setupLogStream(logger); - return new Promise((resolvePromise, reject) => { - const proc = spawn(cliBin, args, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); + return new Promise((resolvePromise, reject) => { + const proc = spawn(cliBin, args, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); - // Pipe or suppress output - if (logStream) { - proc.stdout.pipe(logStream); - proc.stderr.pipe(logStream); - } else { - proc.stdout.resume(); - proc.stderr.resume(); - } + // Always capture stderr for error reporting + const stderrChunks: Buffer[] = []; + const stdoutChunks: Buffer[] = []; - proc.on('close', (code) => { - if (logStream) logStream.end(); - if (code === 0) { - resolvePromise({ logPath }); - } else { - reject( - new Error( - `Generator failed with exit code ${code}.` + - (logPath ? ` See log: ${logPath}` : '') - ) - ); - } - }); + if (logStream) { + proc.stdout.pipe(logStream); + proc.stderr.pipe(logStream); + } else { + proc.stdout.on('data', (chunk) => stdoutChunks.push(chunk)); + } - proc.on('error', (err) => { - if (logStream) logStream.end(); - reject( - new Error( - `Failed to start generator: ${err.message}.` + - (logPath ? ` See log: ${logPath}` : '') - ) - ); - }); - }); + // Always collect stderr regardless of logger setting + proc.stderr.on('data', (chunk) => stderrChunks.push(chunk)); + + proc.on('close', (code) => { + if (logStream) logStream.end(); + const stderrOutput = Buffer.concat(stderrChunks).toString().trim(); + const stdoutOutput = Buffer.concat(stdoutChunks).toString().trim(); + + if (code === 0) { + resolvePromise({ logPath }); + } else { + const details = stderrOutput || stdoutOutput; + reject( + new Error( + `Generator failed with exit code ${code}.` + + (logPath ? ` See log: ${logPath}` : '') + + (details ? `\n\n--- Generator output ---\n${details}` : '') + ) + ); + } + }); + + proc.on('error', (err) => { + if (logStream) logStream.end(); + reject( + new Error( + `Failed to start generator: ${err.message}.` + + (logPath ? ` See log: ${logPath}` : '') + ) + ); + }); + }); } export { runOpenApiGenerator }; diff --git a/packages/core/src/features/registry/install-to-xano.ts b/packages/core/src/features/registry/install-to-xano.ts index 1e720a9..e90fc69 100644 --- a/packages/core/src/features/registry/install-to-xano.ts +++ b/packages/core/src/features/registry/install-to-xano.ts @@ -1,64 +1,58 @@ -import { - BranchConfig, - InstanceConfig, - InstallResults, - RegistryItemFile, - WorkspaceConfig, -} from '@repo/types'; +import { BranchConfig, InstanceConfig, InstallResults, WorkspaceConfig } from '@repo/types'; import type { Caly } from '../..'; import { sortFilesByType } from './general'; import { fetchRegistryFileContent } from './api'; interface InstallParams { - instanceConfig: InstanceConfig; - workspaceConfig: WorkspaceConfig; - branchConfig: BranchConfig; - apiGroupId?: string | number; - file?: any; + instanceConfig: InstanceConfig; + workspaceConfig: WorkspaceConfig; + branchConfig: BranchConfig; + apiGroupId?: string | number; + file?: any; } type UrlResolver = (params: InstallParams) => string; const REGISTRY_MAP: Record = { // Simple static-like paths - 'registry:function': (p) => `function?branch=${p.branchConfig.label}`, + 'registry:function': (p) => `function`, 'registry:table': (p) => `table`, - 'registry:addon': (p) => `addon?branch=${p.branchConfig.label}`, - 'registry:apigroup': (p) => `apigroup?branch=${p.branchConfig.label}`, - 'registry:middleware': (p) => `middleware?branch=${p.branchConfig.label}`, - 'registry:task': (p) => `task?branch=${p.branchConfig.label}`, - 'registry:tool': (p) => `tool?branch=${p.branchConfig.label}`, - 'registry:mcp': (p) => `mcp_server?branch=${p.branchConfig.label}`, - 'registry:agent': (p) => `agent?branch=${p.branchConfig.label}`, - 'registry:realtime': (p) => `realtime/channel?branch=${p.branchConfig.label}`, - 'registry:test': (p) => `workflow_test?branch=${p.branchConfig.label}`, - 'registry:workspace/trigger': (p) => `trigger?branch=${p.branchConfig.label}`, + 'registry:addon': (p) => `addon`, + 'registry:apigroup': (p) => `apigroup`, + 'registry:middleware': (p) => `middleware`, + 'registry:task': (p) => `task`, + 'registry:tool': (p) => `tool`, + 'registry:mcp': (p) => `mcp_server`, + 'registry:agent': (p) => `agent`, + 'registry:realtime': (p) => `realtime/channel`, + 'registry:test': (p) => `workflow_test`, + 'registry:workspace/trigger': (p) => `trigger`, // Complex/Nested paths - 'registry:query': (p) => `apigroup/${p.apiGroupId}/api?branch=${p.branchConfig.label}`, + 'registry:query': (p) => `apigroup/${p.apiGroupId}/api`, 'registry:table/trigger': (p) => { if (!p.file?.tableId) { throw new Error('tableId required for table trigger installation'); } - return `table/${p.file.tableId}/trigger?branch=${p.branchConfig.label}`; + return `table/${p.file.tableId}/trigger`; }, 'registry:mcp/trigger': (p) => { if (!p.file?.mcpId) { throw new Error('mcpId required for MCP trigger installation'); } - return `mcp_server/${p.file.mcpId}/trigger?branch=${p.branchConfig.label}`; + return `mcp_server/${p.file.mcpId}/trigger`; }, 'registry:agent/trigger': (p) => { if (!p.file?.agentId) { throw new Error('agentId required for agent trigger installation'); } - return `agent/${p.file.agentId}/trigger?branch=${p.branchConfig.label}`; + return `agent/${p.file.agentId}/trigger`; }, 'registry:realtime/trigger': (p) => { if (!p.file?.realtimeId) { throw new Error('realtimeId required for realtime trigger installation'); } - return `realtime/channel/${p.file.realtimeId}/trigger?branch=${p.branchConfig.label}`; + return `realtime/channel/${p.file.realtimeId}/trigger`; }, }; @@ -83,45 +77,49 @@ function resolveInstallUrl(type: string, params: InstallParams) { async function installRegistryItemToXano( item: any, - resolvedContext: { instanceConfig: InstanceConfig; workspaceConfig: WorkspaceConfig; branchConfig: BranchConfig }, + resolvedContext: { + instanceConfig: InstanceConfig; + workspaceConfig: WorkspaceConfig; + branchConfig: BranchConfig; + }, registryUrl: string, core: Caly, ) { - const { instanceConfig, workspaceConfig, branchConfig } = resolvedContext; - - if (!instanceConfig) { - throw new Error('instanceConfig is required for registry installation'); - } - - const results: InstallResults = { - installed: [], - failed: [], - skipped: [], - }; - - // Sort files - let filesToInstall = sortFilesByType(item.files || []); - - // Handle content-only registry items - if (filesToInstall.length === 0 && item.content) { - filesToInstall = [{ path: item.name || 'inline', content: item.content, type: item.type }]; - } - - // Load token once - const xanoToken = await core.loadToken(instanceConfig.name); - - for (const file of filesToInstall) { - try { - // Get content: use inline content if present, else fetch from file path - let content; - if (file.content != null) { - content = file.content; - } else if (!file.path) { - throw new Error(`File entry has neither content nor path: ${JSON.stringify(file)}`); - } else { - // Use the safe fetchRegistryFileContent function from api.ts - content = await fetchRegistryFileContent(file, file.path, registryUrl); - } + const { instanceConfig, workspaceConfig, branchConfig } = resolvedContext; + + if (!instanceConfig) { + throw new Error('instanceConfig is required for registry installation'); + } + + const results: InstallResults = { + installed: [], + failed: [], + skipped: [], + }; + + // Sort files + let filesToInstall = sortFilesByType(item.files || []); + + // Handle content-only registry items + if (filesToInstall.length === 0 && item.content) { + filesToInstall = [{ path: item.name || 'inline', content: item.content, type: item.type }]; + } + + // Load token once + const xanoToken = await core.loadToken(instanceConfig.name); + + for (const file of filesToInstall) { + try { + // Get content: use inline content if present, else fetch from file path + let content; + if (file.content != null) { + content = file.content; + } else if (!file.path) { + throw new Error(`File entry has neither content nor path: ${JSON.stringify(file)}`); + } else { + // Use the safe fetchRegistryFileContent function from api.ts + content = await fetchRegistryFileContent(file, file.path, registryUrl); + } // Determine install URL let apiGroupId; @@ -136,17 +134,17 @@ async function installRegistryItemToXano( apiGroupId = file.apiGroupId; } - // Post to Xano - const xanoApiUrl = `${instanceConfig.url}/api:meta`; - const installUrl = resolveInstallUrl(file.type, { - instanceConfig, - workspaceConfig, - branchConfig, - file, - apiGroupId, - }); - - const response = await fetch(`${xanoApiUrl}${installUrl}`, { + // Post to Xano + const xanoApiUrl = `${instanceConfig.url}/api:meta`; + const installUrl = resolveInstallUrl(file.type, { + instanceConfig, + workspaceConfig, + branchConfig, + file, + apiGroupId, + }); + + const response = await fetch(`${xanoApiUrl}${installUrl}`, { method: 'POST', headers: { Authorization: `Bearer ${xanoToken}`, @@ -155,61 +153,61 @@ async function installRegistryItemToXano( body: content, }); - if (response.ok) { - const body = await response.json(); - results.installed.push({ - component: item.name, - file: file.path || '', - response: body, - }); - } else { - // Try to parse the error response to detect "already exists" / duplicate cases - let errorMessage = `HTTP ${response.status}`; - let isSkipped = false; - try { - const errorBody = await response.json(); - if (errorBody?.message) { - errorMessage = errorBody.message; - } - // Detect known "already exists" / duplicate patterns from Xano - const msg = (errorBody?.message || '').toLowerCase(); - if ( - msg.includes('already exists') || - msg.includes('duplicate') || - msg.includes('conflict') || - response.status === 409 - ) { - isSkipped = true; - } - } catch { - // Response was not JSON, use status code only - if (response.status === 409) { - isSkipped = true; - } - } - - if (isSkipped) { - results.skipped.push({ - component: item.name, - file: file.path || '', - error: errorMessage, - }); - } else { - results.failed.push({ - component: item.name, - file: file.path || '', - error: errorMessage, - }); - } - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - results.failed.push({ - component: item.name, - file: file.path || '', - error: message, - }); - } + if (response.ok) { + const body = await response.json(); + results.installed.push({ + component: item.name, + file: file.path || '', + response: body, + }); + } else { + // Try to parse the error response to detect "already exists" / duplicate cases + let errorMessage = `HTTP ${response.status}`; + let isSkipped = false; + try { + const errorBody = await response.json(); + if (errorBody?.message) { + errorMessage = errorBody.message; + } + // Detect known "already exists" / duplicate patterns from Xano + const msg = (errorBody?.message || '').toLowerCase(); + if ( + msg.includes('already exists') || + msg.includes('duplicate') || + msg.includes('conflict') || + response.status === 409 + ) { + isSkipped = true; + } + } catch { + // Response was not JSON, use status code only + if (response.status === 409) { + isSkipped = true; + } + } + + if (isSkipped) { + results.skipped.push({ + component: item.name, + file: file.path || '', + error: errorMessage, + }); + } else { + results.failed.push({ + component: item.name, + file: file.path || '', + error: errorMessage, + }); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + results.failed.push({ + component: item.name, + file: file.path || '', + error: message, + }); + } } return results; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5daba13..beb6e66 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -520,7 +520,7 @@ export type InstallUrlParams = { workspaceConfig: any; branchConfig: any; file: any; - apiGroupId?: string; + apiGroupId?: string | number; }; export type UrlMappingFn = (params: InstallUrlParams) => string; diff --git a/packages/utils/src/methods/sanitize.ts b/packages/utils/src/methods/sanitize.ts index 159d6fa..403ae7f 100644 --- a/packages/utils/src/methods/sanitize.ts +++ b/packages/utils/src/methods/sanitize.ts @@ -6,7 +6,7 @@ const defaultOptions: Required> & { } = { normalizeUnicode: true, removeDiacritics: true, - allowedCharsRegex: /[a-zA-Z0-9-]/, // for dashes, no underscore by default + allowedCharsRegex: /[a-zA-Z0-9-]/u, // for dashes, no underscore by default replacementChar: '-', collapseRepeats: true, trimReplacement: true, @@ -39,16 +39,27 @@ function sanitizeString(input: string, options: SanitizeOptions = {}): string { s = s.normalize('NFKD'); } if (opts.removeDiacritics) { - s = s.replace(/[\u0300-\u036F]/g, ''); + s = s.replace(/[\u0300-\u036F]/gu, ''); } + // Strip emoji and other symbol characters before main sanitization + // Covers emoticons, dingbats, symbols, pictographs, flags, variation selectors, ZWJ, etc. + s = s.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}\u200D\uFE0F\uFE0E]/gu, ''); + if (opts.replacementChar === '-') { s = s.replace(/[\s_]+/g, '-'); } else if (opts.replacementChar === '_') { s = s.replace(/[\s-]+/g, '_'); } - s = s.replace(new RegExp(`[^${opts.allowedCharsRegex.source}]`, 'g'), opts.replacementChar); + // Build the replacement regex from the allowedCharsRegex source. + // The source includes brackets (e.g. "[a-zA-Z0-9-]"), so extract the inner content. + let charClassContent = opts.allowedCharsRegex.source; + const bracketMatch = charClassContent.match(/^\[(.+)\]$/s); + if (bracketMatch) { + charClassContent = bracketMatch[1]; + } + s = s.replace(new RegExp(`[^${charClassContent}]`, 'gu'), opts.replacementChar); if (opts.collapseRepeats) { s = s.replace(new RegExp(`\\${opts.replacementChar}+`, 'g'), opts.replacementChar); @@ -120,7 +131,7 @@ function sanitizeInstanceName(name: string): string { */ function sanitizeFileName(fileName: string): string { return sanitizeString(fileName, { - allowedCharsRegex: /[a-zA-Z0-9._-]/, + allowedCharsRegex: /[a-zA-Z0-9._-]/u, replacementChar: '_', toLowerCase: false, });