UNPKG

@rschili/melodi-cli

Version:
4 lines 309 kB
{ "version": 3, "sources": ["../src/buildInfo.ts", "../package.json", "../src/Diagnostics.ts", "../src/index.ts", "../src/ConsoleHelper.ts", "../src/UserConfig.ts", "../src/SystemFolders.ts", "../src/Context.ts", "../src/EnvironmentManager.ts", "../node_modules/@itwin/itwins-client/src/BaseBentleyAPIClient.ts", "../node_modules/@itwin/itwins-client/src/BaseITwinsApiClient.ts", "../node_modules/@itwin/itwins-client/src/iTwinsClient.ts", "../src/Logic/NewFile.ts", "../src/UnifiedDb.ts", "../src/Logic/DbEditor.ts", "../src/Logic/SchemaEditor.ts", "../src/GithubBisSchemasHelper.ts", "../src/Logic/SchemaEditorOps.ts", "../src/Logic/DbSettings.ts", "../src/Logic/McpServer.ts", "../src/Logic/QueryRunner.ts", "../src/Logic/ecsql-guide.md", "../src/Logic/Changesets.ts", "../src/Logic/ChangesetOps.ts", "../src/Logic/Troubleshooter.ts", "../src/Logic/TroubleshootOps.ts", "../src/IModelConfig.ts", "../src/Logger.ts", "../src/Logic/FileSelector.ts", "../src/Logic/FileSelectorOps.ts"], "sourcesContent": ["export const __BUILD_DATE__ = '2026-03-18T18:11:14Z';\n", "{\n \"name\": \"@rschili/melodi-cli\",\n \"version\": \"1.7.0\",\n \"description\": \"iModel utility\",\n \"main\": \"dist/index.mjs\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=22.14.0\"\n },\n \"scripts\": {\n \"typecheck\": \"tsc --noEmit\",\n \"build\": \"node esbuild.config.mjs\",\n \"test\": \"vitest\",\n \"start\": \"node dist/index.mjs\",\n \"lint\": \"eslint 'src/**/*.ts' 'test/**/*.ts' --fix\",\n \"prebuild\": \"echo \\\"export const __BUILD_DATE__ = '$(date -u +%Y-%m-%dT%H:%M:%SZ)';\\\" > src/buildInfo.ts && npm run lint && npm run typecheck\"\n },\n \"bin\": {\n \"melodi\": \"dist/index.mjs\"\n },\n \"publishConfig\": {\n \"access\": \"public\"\n },\n \"files\": [\n \"dist\",\n \"LICENSE\",\n \"README.md\",\n \"CHANGELOG.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/rschili/melodi-cli.git\"\n },\n \"keywords\": [\n \"itwin\",\n \"imodel\",\n \"bentley\",\n \"ecdb\",\n \"bim\"\n ],\n \"author\": \"Robert Schili\",\n \"license\": \"MIT\",\n \"bugs\": {\n \"url\": \"https://github.com/rschili/melodi-cli/issues\"\n },\n \"homepage\": \"https://github.com/rschili/melodi-cli#readme\",\n \"devDependencies\": {\n \"@eslint/js\": \"^10.0.1\",\n \"@types/node\": \"^25.5.0\",\n \"@vitest/coverage-v8\": \"^4.1.0\",\n \"esbuild\": \"^0.27.4\",\n \"esbuild-node-externals\": \"^1.20.1\",\n \"eslint\": \"^10.0.3\",\n \"globals\": \"^17.4.0\",\n \"typescript\": \"^5.9.3\",\n \"typescript-eslint\": \"^8.57.1\",\n \"vitest\": \"^4.1.0\"\n },\n \"dependencies\": {\n \"@clack/prompts\": \"^1.1.0\",\n \"@itwin/core-backend\": \"^5.7.2\",\n \"@itwin/core-bentley\": \"^5.7.2\",\n \"@itwin/core-common\": \"^5.7.2\",\n \"@itwin/ecschema-metadata\": \"^5.7.2\",\n \"@itwin/imodels-access-backend\": \"^6.0.2\",\n \"@itwin/imodels-access-common\": \"^6.0.2\",\n \"@itwin/imodels-client-authoring\": \"^6.0.2\",\n \"@itwin/itwins-client\": \"^2.5.2\",\n \"@itwin/node-cli-authorization\": \"^3.0.2\",\n \"@itwin/object-storage-azure\": \"^3.0.4\",\n \"@itwin/object-storage-core\": \"^3.0.4\",\n \"@itwin/object-storage-google\": \"^3.0.4\",\n \"@modelcontextprotocol/sdk\": \"^1.27.1\",\n \"@types/semver\": \"^7.7.1\",\n \"axios\": \"^1.13.6\",\n \"chalk\": \"^5.6.2\",\n \"emphasize\": \"^7.0.0\",\n \"globby\": \"^16.1.1\",\n \"gradient-string\": \"^3.0.0\",\n \"module-alias\": \"^2.3.4\",\n \"semver\": \"^7.7.4\",\n \"simple-update-notifier\": \"^2.0.0\",\n \"table\": \"^6.9.0\",\n \"zod\": \"^4.3.6\"\n },\n \"overrides\": {\n \"inversify\": \"7.5.2\",\n \"reflect-metadata\": \"^0.2.2\"\n }\n}\n", "import { __BUILD_DATE__ } from \"./buildInfo\";\nimport pkg from '../package.json'\nimport updateNotifier from 'simple-update-notifier'\n\nexport const applicationVersion: string = pkg.version;\n\nexport const applicationBuildDate: string = new Date(__BUILD_DATE__).toLocaleString();\n\nexport async function checkUpdates(): Promise<void> {\n await updateNotifier({ pkg: pkg });\n}", "#!/usr/bin/env node\n\nimport { applicationBuildDate, applicationVersion, checkUpdates } from \"./Diagnostics\";\nimport gradient from \"gradient-string\";\nimport { formatPath, formatSuccess, printError } from \"./ConsoleHelper\";\nimport { readUserConfig } from \"./UserConfig\";\nimport chalk from \"chalk\";\nimport { getCacheDir, getConfigDir, getRootDir } from \"./SystemFolders\";\nimport { promises as fs } from \"fs\";\nimport { Context, loadContext } from \"./Context\";\nimport { FileSelector } from \"./Logic/FileSelector\";\n\nprocess.on(\"unhandledRejection\", (reason) => {\n console.error(\"Unhandled promise rejection:\", reason);\n process.exit(10);\n});\nprocess.on(\"uncaughtException\", (err) => {\n console.error(\"Uncaught exception:\", err);\n process.exit(5);\n});\n\nconst [major, minor] = process.versions.node.split('.').map(Number);\nif (major < 22 || (major === 22 && minor < 14)) {\n console.error(chalk.yellowBright(`Warning: melodi-cli requires Node.js 22.14 or newer. You are running ${process.versions.node}.`));\n}\n\nconst subtitle = ` iModel repository utility built ${applicationBuildDate} `;\nconst separatorLine = \"-\".repeat(subtitle.length);\nconst banner =\n ` _ _ _ \n _ __ ___ ___ | | ___ __| | (_)\n | '_ \\` _ \\\\ / _ \\\\ | | / _ \\\\ / _\\` | | |\n | | | | | | | __/ | | | (_) | | (_| | | |\n |_| |_| |_| \\\\___| |_| \\\\___/ \\\\__,_| |_| CLI v${applicationVersion}\n${separatorLine}\n ${subtitle}\n${separatorLine}`;\nconsole.log(gradient(['cyan', 'white']).multiline(banner));\ntry {\n await checkUpdates();\n const args = process.argv.slice(2);\n if (args.length == 1) {\n if (args[0] === '-v' || args[0] === '--version' || args[0] === 'version') {\n console.log(`Version ${applicationVersion}`);\n process.exit(0);\n }\n if (args[0] === '-h' || args[0] === '--help' || args[0] === 'help') {\n console.log(`Version ${applicationVersion}`);\n console.log(`Usage: melodi-cli [options]`);\n console.log(`Options:`);\n console.log(` -v, --version Show version number`);\n console.log(` -h, --help Show this help message`);\n console.log();\n console.log(\"The tool is designed to be interactive, so it is usually run without arguments.\");\n console.log(\"Location of workspace, config and cache can be overwritten using environment variables.\");\n console.log(\" - MELODI_CONFIG: Location of the config directory\");\n console.log(\" - MELODI_CACHE: Location of the cache directory\");\n console.log(\" - MELODI_ROOT: Location of the root directory\");\n process.exit(0);\n }\n }\n\n const cacheDir = getCacheDir();\n const configDir = getConfigDir();\n const rootDir = getRootDir();\n // Ensure directories exist\n await fs.mkdir(cacheDir, { recursive: true });\n await fs.mkdir(configDir, { recursive: true });\n await fs.mkdir(rootDir, { recursive: true });\n const userConfig = await readUserConfig(configDir);\n console.log(`Using config directory: ${formatPath(configDir)}`);\n console.log(`Using cache directory: ${formatPath(cacheDir)}`);\n console.log(`Using documents directory: ${formatPath(rootDir)}`);\n const ctx: Context = await loadContext(userConfig, { cacheDir, configDir, rootDir });\n if(ctx.commandCache.melodiVersion !== applicationVersion) {\n console.log(formatSuccess(`The workspace was saved using a different version of melodi (${ctx.commandCache.melodiVersion}). Running version (${applicationVersion}).`));\n }\n\n try {\n await ctx.envManager.startup();\n await FileSelector.run(ctx);\n } finally {\n await ctx.envManager.shutdown();\n }\n process.exit(0);\n} catch (error: unknown) {\n if (error instanceof Error && error.name === 'ExitPromptError') { // ctrl+c on inquirer\n process.exit(0);\n }\n printError(error);\n process.exit(1);\n}\n\n", "import { log } from \"@clack/prompts\";\nimport chalk from \"chalk\";\n\nexport const formatPath = chalk.blueBright.underline;\nexport const formatError = chalk.redBright.bold;\nexport const formatWarning = chalk.yellowBright;\nexport const formatSuccess = chalk.greenBright.bold;\nexport const resetChar = \"\\x1B[0m\";\n\nexport function printError(error: unknown, printAsWarning: boolean = false): void {\n const formatter = printAsWarning ? formatWarning : formatError;\n const label = printAsWarning ? \"Warning\" : \"Error\";\n if (error instanceof Error) {\n console.error(formatter(`${label}: ${error.message}`));\n /*if (error.stack) {\n console.error(chalk.gray(error.stack));\n }*/\n } else {\n console.error(formatter(`${label}: ${String(error)}`));\n }\n}\n\nexport function logError(error: unknown): void {\n if (error instanceof Error) {\n log.error(error.message);\n /*if (error.stack) {\n console.error(chalk.gray(error.stack));\n }*/\n } else {\n log.error(`Error: ${String(error)}`);\n }\n}\n\nexport function generateColorizerMap<T>(values: T[]): Map<T, (text: string) => string> {\n const colorizerMap = new Map<T, (text: string) => string>();\n const colors = [\n chalk.redBright,\n chalk.greenBright,\n chalk.blueBright,\n chalk.yellowBright,\n chalk.cyanBright,\n chalk.magentaBright,\n chalk.whiteBright,\n ];\n\n const uniqueValues = Array.from(new Set(values));\n uniqueValues.forEach((value, index) => {\n const color = colors[index % colors.length];\n colorizerMap.set(value, color);\n });\n\n return colorizerMap;\n}\n\nconst msInSecond = 1000;\nconst msInMinute = msInSecond * 60;\nconst msInHour = msInMinute * 60;\nconst msInDay = msInHour * 24;\nconst msInYear = msInDay * 365.25;\nexport function timeSpanToString(span: number): string | undefined {\n if (span > msInYear * 100 || span <= 0) {\n return undefined;\n }\n\n if (span < msInMinute) {\n const seconds = Math.floor(span / msInSecond);\n return `${seconds} second${seconds !== 1 ? \"s\" : \"\"}`;\n } else if (span < msInHour) {\n const minutes = Math.floor(span / msInMinute);\n return `${minutes} minute${minutes !== 1 ? \"s\" : \"\"}`;\n } else if (span < msInDay) {\n const hours = Math.floor(span / msInHour);\n return `${hours} hour${hours !== 1 ? \"s\" : \"\"}`;\n } else if (span < msInYear) {\n const days = Math.floor(span / msInDay);\n return `${days} day${days !== 1 ? \"s\" : \"\"}`;\n } else {\n const years = Math.floor(span / msInYear);\n return `${years} year${years !== 1 ? \"s\" : \"\"}`;\n }\n}", "import * as fs from 'fs';\nimport path from \"path\";\nimport { z } from \"zod/v4\";\nimport { printError, formatError } from \"./ConsoleHelper\";\nimport { applicationVersion } from \"./Diagnostics\";\n\nexport enum LogLevel { // copied so it's independent of @itwin/core-bentley\n /** Tracing and debugging - low level */\n Trace = 0,\n /** Information - mid level */\n Info = 1,\n /** Warnings - high level */\n Warning = 2,\n /** Errors - highest level */\n Error = 3,\n /** Higher than any real logging level. This is used to turn a category off. */\n None = 4\n}\n\nconst UserConfigSchema = z.object({\n melodiVersion: z.string(),\n logging: z.enum(LogLevel).optional(),\n});\n\nexport type UserConfig = z.infer<typeof UserConfigSchema>;\n\nexport const UserConfigFileName = 'config.json';\n\nexport async function readUserConfig(userConfigDir: string): Promise<UserConfig> {\n try {\n const userConfigPath = path.join(userConfigDir, UserConfigFileName);\n if (fs.existsSync(userConfigPath)) {\n // If the user config does not exist, return the default user config\n const data = await fs.promises.readFile(userConfigPath, 'utf-8');\n const json = JSON.parse(data);\n return await UserConfigSchema.parseAsync(json);\n }\n } catch (err: unknown) {\n console.error(formatError(\"Failed to read user config. Using default config.\"));\n printError(err);\n }\n\n return {\n melodiVersion: applicationVersion,\n logging: LogLevel.None,\n };\n}\n\nexport async function saveUserConfig(cfg: UserConfig, userConfigDir: string): Promise<void> {\n const userConfigPath = path.join(userConfigDir, UserConfigFileName);\n // Validate the config before saving\n if (!fs.lstatSync(userConfigDir).isDirectory()) {\n throw new Error(`The user config directory is not a valid directory: ${userConfigDir}`);\n }\n\n try {\n fs.accessSync(userConfigDir, fs.constants.R_OK | fs.constants.W_OK);\n } catch {\n throw new Error(`The user config directory is not accessible: ${userConfigDir}. Please check permissions.`);\n }\n\n cfg.melodiVersion = applicationVersion; // Ensure the version is up-to-date\n const data = JSON.stringify(cfg, undefined, 2);\n await fs.promises.writeFile(userConfigPath, data, 'utf-8');\n}\n", "import os from \"os\";\nimport path from \"path\";\nimport fs from \"fs\";\n\nconst MELODI_CONFIG_ENV = \"MELODI_CONFIG\";\nconst MELODI_CACHE_ENV = \"MELODI_CACHE\";\nconst MELODI_ROOT_ENV = \"MELODI_ROOT\";\n\nconst appName = 'melodi';\n\n\nexport function getConfigDir(): string {\n if (process.env[MELODI_CONFIG_ENV]) {\n return process.env[MELODI_CONFIG_ENV];\n }\n \n const home = os.homedir();\n switch (process.platform) {\n case \"win32\":\n return path.join(process.env.LOCALAPPDATA || path.join(home, \"AppData\", \"Local\"), appName, \"config\");\n case \"darwin\":\n case \"linux\":\n default:\n return path.join(process.env.XDG_CONFIG_HOME || path.join(home, \".config\"), appName);\n }\n}\n\nexport function getCacheDir(): string {\n if (process.env[MELODI_CACHE_ENV]) {\n return process.env[MELODI_CACHE_ENV];\n }\n \n const home = os.homedir();\n switch (process.platform) {\n case \"win32\":\n return path.join(process.env.LOCALAPPDATA || path.join(home, \"AppData\", \"Local\"), appName, \"cache\");\n case \"darwin\":\n return path.join(process.env.XDG_CACHE_HOME || path.join(home, \"Library\", \"Caches\"), appName);\n case \"linux\":\n default:\n return path.join(process.env.XDG_CACHE_HOME || path.join(home, \".cache\"), appName);\n }\n}\n\nexport function getRootDir(): string {\n if (process.env[MELODI_ROOT_ENV]) {\n return process.env[MELODI_ROOT_ENV];\n }\n \n const home = os.homedir();\n\n if (process.platform === \"win32\") {\n // Windows Known Folders: usually %USERPROFILE%\\Documents\n const docs = path.join(process.env.USERPROFILE || home, \"Documents\");\n return path.join(docs, appName);\n }\n\n if (process.platform === \"darwin\") {\n // macOS: ~/Documents\n const docs = path.join(home, \"Documents\");\n return path.join(docs, appName);\n }\n\n if (process.platform === \"linux\") {\n // Linux: use XDG user-dirs if available, fallback to ~/Documents\n const userDirsFile = path.join(home, \".config\", \"user-dirs.dirs\");\n let docs: string | null = null;\n\n if (fs.existsSync(userDirsFile)) {\n const content = fs.readFileSync(userDirsFile, \"utf8\");\n const match = content.match(/XDG_DOCUMENTS_DIR=\"?([^\"\\n]+)\"?/);\n if (match) {\n docs = match[1].replace(\"$HOME\", home);\n }\n }\n\n if (!docs) {\n docs = path.join(home, \"Documents\");\n }\n\n return path.join(docs, appName);\n }\n\n // fallback\n return path.join(home, appName);\n}\n", "import * as fs from 'fs';\nimport path from \"path\";\nimport { z } from \"zod/v4\";\nimport { globby } from 'globby';\nimport { SQLiteDb, SqliteStatement } from \"@itwin/core-backend\";\nimport { DbResult, OpenMode } from \"@itwin/core-bentley\";\nimport { printError } from \"./ConsoleHelper\";\nimport { SemVer } from \"semver\";\nimport { applicationVersion } from \"./Diagnostics\";\nimport { EnvironmentManager } from \"./EnvironmentManager\";\nimport { UserConfig } from \"./UserConfig\";\n\nconst CommandCacheSchema = z.object({\n melodiVersion: z.string(),\n ecsqlHistory: z.array(z.string()).optional(),\n sqliteHistory: z.array(z.string()).optional(),\n});\n\nexport type CommandCache = z.infer<typeof CommandCacheSchema>;\n\nexport const CommandHistoryFileName = 'commandHistory.json';\n\nexport type MelodiFolders = {\n configDir: string;\n cacheDir: string;\n rootDir: string;\n};\n\nexport type Context = {\n folders: MelodiFolders;\n commandCache: CommandCache;\n userConfig: UserConfig;\n files?: WorkspaceFile[];\n envManager: EnvironmentManager;\n}\n\nexport type WorkspaceFile = {\n relativePath: string;\n lastTouched: Date;\n parentChangeSetId?: string;\n beDbVersion?: SchemaVersion;\n ecDbVersion?: SchemaVersion;\n dgn_DbVersion?: SchemaVersion;\n bisCoreVersion?: SemVer;\n elements?: number; // Optional: number of bis_Element records in the iModel, if applicable\n hasITwinId: boolean\n}\n\nexport async function loadContext(userConfig: UserConfig, folders: MelodiFolders): Promise<Context> {\n // check if there is a \".melodi\" subdirectory in the current working directory\n \n try {\n fs.accessSync(folders.rootDir, fs.constants.R_OK | fs.constants.W_OK)\n }\n catch {\n throw new Error(`The root directory is not accessible: ${folders.rootDir}. Please check permissions.`);\n }\n\n const environment = new EnvironmentManager(folders.cacheDir);\n const commandHistoryPath = path.join(folders.cacheDir, CommandHistoryFileName);\n\n if (!fs.existsSync(commandHistoryPath)) {\n return {\n folders,\n commandCache: {\n melodiVersion: applicationVersion,\n ecsqlHistory: []\n },\n envManager: environment,\n userConfig\n };\n }\n\n const commandHistory = await readCommandHistory(commandHistoryPath);\n\n return {\n folders,\n commandCache: commandHistory,\n envManager: environment,\n userConfig\n };\n}\n\n// Read and validate config\nexport async function readCommandHistory(filePath: string): Promise<CommandCache> {\n const data = await fs.promises.readFile(filePath, 'utf-8');\n const json = JSON.parse(data);\n return await CommandCacheSchema.parseAsync(json);\n}\n\n// Save config (overwrite)\nexport async function saveCommandHistory(ctx: Context): Promise<void> {\n if(ctx.commandCache === undefined) {\n throw new Error(\"Command history is undefined. Please provide a valid config.\");\n }\n\n // Validate the config before saving\n if (!fs.existsSync(ctx.folders.cacheDir)) {\n await fs.promises.mkdir(ctx.folders.cacheDir, { recursive: true });\n }\n\n if (!fs.lstatSync(ctx.folders.cacheDir).isDirectory()) {\n throw new Error(`The cache directory is not a valid directory: ${ctx.folders.cacheDir}`);\n }\n\n try {\n fs.accessSync(ctx.folders.cacheDir, fs.constants.R_OK | fs.constants.W_OK);\n } catch {\n throw new Error(`The cache directory is not accessible: ${ctx.folders.cacheDir}. Please check permissions.`);\n }\n\n const filePath = path.join(ctx.folders.cacheDir, CommandHistoryFileName);\n ctx.commandCache.melodiVersion = applicationVersion; // Ensure the version is up-to-date\n const data = JSON.stringify(ctx.commandCache, undefined, 2);\n await fs.promises.writeFile(filePath, data, 'utf-8');\n}\n\nexport async function detectFiles(ctx: Context): Promise<void> {\n const patterns = [\n '**/*.bim',\n '**/*.ecdb',\n ];\n\n const ignore = [\n '**/.*', // Ignore dotfiles and dotfolders\n '**/.*/**', // Also ignore anything inside dotfolders\n ];\n\n const files = await globby(patterns, {\n cwd: ctx.folders.rootDir,\n absolute: false,\n deep: 2, // Limit to 2 levels deep\n dot: false, // Don't match dotfiles/folders\n ignore,\n caseSensitiveMatch: false,\n });\n\n const workspaceFiles: WorkspaceFile[] = files.map(file => {\n const absolutePath = path.join(ctx.folders.rootDir, file);\n\n const stats = fs.statSync(absolutePath);\n const lastTouched = new Date(Math.max(stats.mtime.getTime(), stats.birthtime.getTime(), stats.ctime.getTime()));\n\n return {\n relativePath: file,\n lastTouched,\n hasITwinId: false,\n };\n });\n\n await readFileProps(ctx, workspaceFiles);\n ctx.files = workspaceFiles;\n}\n// Folder to hold context information for a file. for file /home/user/workspace/file.bim the folder would be /home/user/workspace/file_extras/\nexport function getFileContextFolderPath(root: string, relativeFilePath: string): string {\n const parsed = path.parse(relativeFilePath);\n const contextFolderName = `${parsed.name}_extras`;\n return path.join(root, parsed.dir, contextFolderName);\n}\n\nconst schemaVersionSchema = z.object({\n major: z.number(),\n minor: z.number(),\n sub1: z.number(),\n sub2: z.number(),\n});\n\nexport type SchemaVersion = z.infer<typeof schemaVersionSchema>;\n\nasync function readFileProps(ctx: Context, files: WorkspaceFile[]): Promise<void> {\n if (files.length === 0) {\n return;\n }\n\n // ItwinId: \n // Count(*) FROM be_Prop WHERE Namespace='be_Db' AND Name='ProjectGuid' AND Id=0 AND SubId=0 AND DATA <> NULL\"\n\n const db = new SQLiteDb();\n for (const file of files) {\n try {\n const absolutePath = path.join(ctx.folders.rootDir, file.relativePath);\n db.openDb(absolutePath, OpenMode.Readonly);\n db.withPreparedSqliteStatement(\"SELECT Name, Val FROM be_Local\", (stmt: SqliteStatement) => {\n while (stmt.step() === DbResult.BE_SQLITE_ROW) {\n const name = stmt.getValueString(0);\n if(name === \"ParentChangeSetId\") {\n file.parentChangeSetId = stmt.getValueString(1);\n }\n }\n });\n \n db.withPreparedSqliteStatement(\"SELECT Namespace, StrData FROM be_Prop WHERE Name = ?\", (stmt: SqliteStatement) => {\n stmt.bindString(1, \"SchemaVersion\");\n while (stmt.step() === DbResult.BE_SQLITE_ROW) {\n const namespace = stmt.getValueString(0);\n const schemaVersion = stmt.getValueString(1);\n const parsedSchemaVersion = schemaVersionSchema.safeParse(JSON.parse(schemaVersion));\n if (parsedSchemaVersion.success) {\n switch (namespace.toLowerCase()) {\n case \"be_db\":\n file.beDbVersion = parsedSchemaVersion.data;\n break;\n case \"ec_db\":\n file.ecDbVersion = parsedSchemaVersion.data;\n break;\n case \"dgn_db\":\n file.dgn_DbVersion = parsedSchemaVersion.data;\n break;\n default:\n console.warn(`Unknown schema version namespace: ${namespace}. This may not be supported by melodi.`);\n break;\n }\n }\n }\n });\n\n db.withPreparedSqliteStatement(\"SELECT VersionDigit1, VersionDigit2, VersionDigit3 from ec_Schema WHERE Name = ?\", (stmt: SqliteStatement) => {\n stmt.bindString(1, \"BisCore\");\n if (stmt.step() === DbResult.BE_SQLITE_ROW) {\n const major = stmt.getValueInteger(0);\n const minor = stmt.getValueInteger(1);\n const sub1 = stmt.getValueInteger(2);\n file.bisCoreVersion = new SemVer(`${major}.${minor}.${sub1}`);\n }\n });\n\n if(file.bisCoreVersion !== undefined) {\n db.withPreparedSqliteStatement(\"SELECT COUNT(*) FROM bis_Element\", (stmt: SqliteStatement) => {\n if (stmt.step() === DbResult.BE_SQLITE_ROW) {\n file.elements = stmt.getValueInteger(0);\n }\n });\n }\n\n db.withPreparedSqliteStatement(\"SELECT Count(*) FROM be_Prop WHERE Namespace='be_Db' AND Name='ProjectGuid' AND Id=0 AND SubId=0 AND DATA NOT NULL\", (stmt: SqliteStatement) => {\n if (stmt.step() === DbResult.BE_SQLITE_ROW) {\n file.hasITwinId = true;\n }\n });\n\n db.closeDb();\n } catch (error) {\n printError(error, true);\n } finally {\n if(db.isOpen) {\n db.closeDb();\n }\n }\n }\n}\n", "import { select } from \"@clack/prompts\";\nimport { IModelHost } from \"@itwin/core-backend\";\nimport { IModelsClient, IModelsClientOptions } from \"@itwin/imodels-client-authoring\";\nimport { ITwinsClient } from \"@itwin/itwins-client\";\nimport { NodeCliAuthorizationClient } from \"@itwin/node-cli-authorization\";\nimport { AzureClientStorage, BlockBlobClientWrapperFactory } from \"@itwin/object-storage-azure\";\nimport { StrategyClientStorage } from \"@itwin/object-storage-core\";\nimport { GoogleClientStorage } from \"@itwin/object-storage-google/lib/client/index.js\";\nimport { ClientStorageWrapperFactory } from \"@itwin/object-storage-google/lib/client/wrappers/index.js\";\nimport { AccessTokenAdapter } from \"@itwin/imodels-access-common\";\nimport { Authorization } from \"@itwin/imodels-client-management\";\nimport { BackendIModelsAccess } from \"@itwin/imodels-access-backend\";\n\n\nexport enum Environment {\n PROD = 'PROD',\n QA = 'QA'\n}\n\n/**\n * This manages all clients that need to be initialized based on a selected environment.\n * Makes it easier to switch between environments and ensures that the clients are properly initialized.\n */\nexport class EnvironmentManager {\n private _cacheDir: string;\n private _currentEnvironment: Environment = Environment.PROD;\n private _authClient?: NodeCliAuthorizationClient;\n private _iModelsClient?: IModelsClient;\n private _iTwinsClient?: ITwinsClient;\n private _isSignedIn: boolean = false;\n private _isStartedUp: boolean = false;\n\n constructor(cacheDir: string) {\n this._cacheDir = cacheDir;\n }\n\n public get currentEnvironment(): Environment {\n return this._currentEnvironment;\n }\n\n public get cacheDirectory(): string {\n return this._cacheDir;\n }\n\n public async selectEnvironment(newEnvironment: Environment): Promise<void> {\n if(newEnvironment !== this._currentEnvironment) {\n await this.shutdown();\n this._currentEnvironment = newEnvironment;\n await this.startup();\n }\n }\n\n public async startup(): Promise<void> {\n if(this._isStartedUp) {\n return;\n }\n\n const hubAccess = new BackendIModelsAccess(this.iModelsClient);\n await IModelHost.startup({ \n cacheDir: this._cacheDir,\n hubAccess: hubAccess,\n authorizationClient: this.authClient,\n });\n this._isStartedUp = true;\n }\n\n public async shutdown(): Promise<void> {\n if(this._isStartedUp) {\n this._authClient = undefined;\n this._iModelsClient = undefined;\n this._iTwinsClient = undefined;\n this._isSignedIn = false;\n this._isStartedUp = false;\n\n await IModelHost.shutdown();\n }\n }\n\n public get authority(): string {\n if(this._currentEnvironment === Environment.PROD) {\n return \"https://ims.bentley.com/\";\n }\n\n if(this._currentEnvironment === Environment.QA) {\n return \"https://qa-ims.bentley.com/\";\n }\n\n throw new Error(`Unknown environment: ${this._currentEnvironment}`);\n }\n\n public get clientId(): string {\n switch (this._currentEnvironment) {\n case Environment.PROD:\n return \"native-b517RwSFtag94aBZ5lM40QCf6\";\n case Environment.QA:\n return \"native-jq2fZ8ZMoMjTKVDghCOpjY4JQ\";\n default:\n throw new Error(`Unknown environment: ${this._currentEnvironment}`);\n }\n }\n\n public async getAccessToken(): Promise<{scheme: string, token: string}> {\n if (this._authClient === undefined) {\n throw new Error(\"Authorization client is not initialized. Call signInIfNecessary() first.\");\n }\n\n const parts = (await this._authClient.getAccessToken()).split(\" \");\n return { scheme: parts[0], token: parts[1] };\n }\n\n public async getAuthorization(): Promise<Authorization> {\n if (this._authClient === undefined) {\n throw new Error(\"Authorization client is not initialized. Call signInIfNecessary() first.\");\n }\n\n return AccessTokenAdapter.toAuthorization(await this._authClient!.getAccessToken());\n }\n public get authClient(): NodeCliAuthorizationClient {\n if (!this._authClient) {\n this._authClient = new NodeCliAuthorizationClient({\n issuerUrl: this.authority,\n clientId: this.clientId,\n redirectUri: \"http://localhost:3000/signin-callback\",\n scope: \"itwin-platform\"\n });\n }\n\n return this._authClient;\n }\n\n public async signInIfNecessary(): Promise<void> {\n if (!this._isSignedIn) {\n await this.authClient.signIn();\n }\n }\n\n public get iModelsClient(): IModelsClient {\n if (!this._iModelsClient) {\n const iModelsClientOptions: IModelsClientOptions = {\n cloudStorage: new StrategyClientStorage([\n {\n instanceName: \"azure\",\n instance: new AzureClientStorage(new BlockBlobClientWrapperFactory()),\n },\n {\n instanceName: \"google\",\n instance: new GoogleClientStorage(new ClientStorageWrapperFactory()),\n }\n ]),\n api: this._currentEnvironment === Environment.QA ? { baseUrl: \"https://qa-api.bentley.com/imodels\" } : undefined,\n }\n\n this._iModelsClient = new IModelsClient(iModelsClientOptions);\n }\n return this._iModelsClient;\n }\n\n public get iTwinsClient(): ITwinsClient {\n if (!this._iTwinsClient) {\n this._iTwinsClient = new ITwinsClient(this._currentEnvironment === Environment.QA ? \"https://qa-api.bentley.com/itwins\" : undefined);\n }\n return this._iTwinsClient;\n }\n\n public async promptEnvironment() : Promise<Environment | symbol> {\n return await select({\n message: \"Select an environment\",\n options: [\n {label: \"PROD\", value: Environment.PROD },\n {label: \"QA\", value: Environment.QA },\n ],\n initialValue: this._currentEnvironment,\n });\n }\n}", "/*---------------------------------------------------------------------------------------------\n * Copyright (c) Bentley Systems, Incorporated. All rights reserved.\n * See LICENSE.md in the project root for license terms and full copyright notice.\n *--------------------------------------------------------------------------------------------*/\nimport type { AccessToken } from \"@itwin/core-bentley\";\nimport type { ApimError, BentleyAPIResponse, Method, RequestConfig } from \"./types/CommonApiTypes\";\nimport { ParameterMapping } from \"./types/typeUtils\";\n\n/**\n * Type guard to validate if an object is a valid Error structure\n * @param error - Unknown object to validate\n * @returns True if the object is a valid Error type\n */\nfunction isValidError(error: unknown): error is ApimError {\n if (typeof error !== \"object\" || error === null) {\n return false;\n }\n\n const obj = error as Record<string, unknown>;\n return typeof obj.code === \"string\" && typeof obj.message === \"string\";\n}\n\n/**\n * Type guard to validate if response data contains an error\n * @param data - Unknown response data to validate\n * @returns True if the data contains a valid Error object\n */\nfunction isErrorResponse(data: unknown): data is { error: ApimError } {\n if (typeof data !== \"object\" || data === null) {\n return false;\n }\n\n const obj = data as Record<string, unknown>;\n return \"error\" in obj && isValidError(obj.error);\n}\n\n/**\n * Base client class providing common functionality for iTwins API requests.\n * Handles authentication, request configuration, and query string building, and error validation.\n */\nexport abstract class BaseBentleyAPIClient {\n\n\n /**\n * The max redirects for iTwins API endpoints.\n * The max redirects can be customized via the constructor parameter or automatically\n * modified based on the IMJS_MAX_REDIRECTS environment variable.\n *\n * @readonly\n */\n protected readonly _maxRedirects: number = 5;\n\n /**\n * Creates a new BaseClient instance for API operations\n * @param maxRedirects - Optional custom max redirects, defaults to 5\n *\n * @example\n * ```typescript\n * // Use default max redirects\n * const client = new BaseClient();\n *\n * // Use custom max redirects\n * const client = new BaseClient(10);\n * ```\n */\n public constructor(maxRedirects?: number) {\n if (maxRedirects !== undefined) {\n this._maxRedirects = maxRedirects;\n } else {\n this._maxRedirects = globalThis.IMJS_MAX_REDIRECTS ?? 5;\n }\n }\n\n\n /**\n * Sends a generic API request with type safety and response validation.\n * Handles authentication, error responses, and data extraction automatically.\n * Error responses follow APIM standards for consistent error handling.\n *\n * @param accessToken - The client access token for authentication\n * @param method - The HTTP method type (GET, POST, DELETE, etc.)\n * @param url - The complete URL of the request endpoint\n * @param data - Optional payload data for the request body\n * @param headers - Optional additional request headers\n * @returns Promise that resolves to the parsed API response with type safety\n */\n protected async sendGenericAPIRequest<TResponse = unknown, TData = unknown>(\n accessToken: AccessToken,\n method: Method,\n url: string,\n data?: TData,\n headers?: Record<string, string>,\n allowRedirects: boolean = false\n ): Promise<BentleyAPIResponse<TResponse>> {\n try {\n const requestOptions = this.createRequestOptions(\n accessToken,\n method,\n url,\n data,\n headers\n );\n\n const response = await fetch(requestOptions.url, {\n method: requestOptions.method,\n headers: requestOptions.headers,\n body: requestOptions.body,\n redirect: 'manual',\n });\n\n // Browser fetch returns an opaque redirect when redirect is set to manual\n if (response.type === \"opaqueredirect\") {\n if (!allowRedirects) {\n return {\n status: 403,\n error: {\n code: \"RedirectsNotAllowed\",\n message: \"Redirects are not allowed for this request.\",\n },\n };\n }\n\n return await this.followRedirectWithFetchFollow<TResponse>(\n requestOptions\n );\n }\n\n // Handle 302 redirects with auth header forwarding\n if (response.status === 302) {\n if (!allowRedirects) {\n return {\n status: 403,\n error: {\n code: \"RedirectsNotAllowed\",\n message: \"Redirects are not allowed for this request.\",\n },\n };\n }\n return await this.followRedirect<TResponse, TData>(\n response,\n accessToken,\n method,\n data,\n headers\n );\n }\n\n // Process non-redirect response\n return await this.processResponse<TResponse>(response);\n } catch {\n return this.createInternalServerError();\n }\n }\n\n /**\n * Follows redirects using the fetch default 'follow' behavior.\n * Used for environments where manual redirect returns opaque responses.\n *\n * @param requestOptions - The original request options\n * @returns Promise that resolves to the final API response\n */\n private async followRedirectWithFetchFollow<TResponse = unknown>(\n requestOptions: RequestConfig\n ): Promise<BentleyAPIResponse<TResponse>> {\n try {\n const response = await fetch(requestOptions.url, {\n method: requestOptions.method,\n headers: requestOptions.headers,\n body: requestOptions.body,\n redirect: \"follow\",\n });\n\n if (response.redirected) {\n try {\n this.validateRedirectUrlSecurity(response.url);\n } catch (error) {\n return {\n status: 502,\n error: {\n code: \"InvalidRedirectUrl\",\n message:\n error instanceof Error ? error.message : \"Invalid redirect URL\",\n },\n };\n }\n }\n\n return await this.processResponse<TResponse>(response);\n } catch {\n return this.createInternalServerError();\n }\n }\n\n /**\n * Handles 302 redirect responses by validating and following the redirect.\n *\n * @param response - The 302 redirect response\n * @param accessToken - The client access token\n * @param method - The HTTP method\n * @param data - Optional request payload\n * @param headers - Optional request headers (will be forwarded to redirect)\n * @param redirectCount - Current redirect depth\n * @returns Promise that resolves to the final API response\n */\n private async followRedirect<TResponse = unknown, TData = unknown>(\n response: Response,\n accessToken: AccessToken,\n method: Method,\n data: TData | undefined,\n headers: Record<string, string> | undefined,\n redirectCount: number = 0\n ): Promise<BentleyAPIResponse<TResponse>> {\n\n // Verify redirect is valid and safe to follow\n const verificationResult = this.checkRedirectValidity(response, redirectCount);\n if (verificationResult.error) {\n return verificationResult.error;\n }\n const redirectUrl = verificationResult.redirectUrl;\n\n try {\n const requestOptions = this.createRequestOptions(\n accessToken,\n method,\n redirectUrl,\n data,\n headers\n );\n\n const redirectResponse = await fetch(requestOptions.url, {\n method: requestOptions.method,\n headers: requestOptions.headers,\n body: requestOptions.body,\n redirect: 'manual',\n });\n\n // Handle subsequent 302 redirects\n if (redirectResponse.status === 302) {\n return await this.followRedirect<TResponse, TData>(\n redirectResponse,\n accessToken,\n method,\n data,\n headers,\n redirectCount + 1\n );\n }\n\n // Process final response\n return await this.processResponse<TResponse>(redirectResponse);\n } catch {\n return this.createInternalServerError();\n }\n }\n\n /**\n * Processes a non-redirect HTTP response.\n *\n * @param response - The HTTP response to process\n * @returns Promise that resolves to a typed API response\n */\n private async processResponse<TResponse>(\n response: Response\n ): Promise<BentleyAPIResponse<TResponse>> {\n const responseData =\n response.status !== 204 ? await response.json() : undefined;\n\n if (!response.ok) {\n if (isErrorResponse(responseData)) {\n return {\n status: response.status,\n error: responseData.error,\n };\n }\n throw new Error(\"An error occurred while processing the request\");\n }\n\n return {\n status: response.status,\n data:\n responseData === undefined || responseData === \"\"\n ? undefined\n : (responseData as TResponse),\n };\n }\n\n /**\n * Creates a generic internal server error response.\n *\n * @returns A 500 error response for internal exceptions\n */\n private createInternalServerError(): BentleyAPIResponse<never> {\n return {\n status: 500,\n error: {\n code: \"InternalServerError\",\n message:\n \"An internal exception happened while calling iTwins Service\",\n },\n };\n }\n\n\n /**\n * Verifies that a redirect response is valid and safe to follow.\n * Performs three critical validations:\n * 1. Checks redirect count to prevent infinite loops\n * 2. Ensures Location header is present\n * 3. Validates redirect URL for security\n *\n * @param response - The 302 redirect response to verify\n * @param redirectCount - Current redirect depth\n * @returns Verification result with either error or validated redirect URL\n */\n private checkRedirectValidity(\n response: Response,\n redirectCount: number\n ): { error?: BentleyAPIResponse<never>; redirectUrl: string } {\n // Check redirect limit to prevent infinite loops\n if (redirectCount >= this._maxRedirects) {\n return {\n error: {\n status: 508,\n error: {\n code: \"TooManyRedirects\",\n message: `Maximum redirect limit (${this._maxRedirects}) exceeded. Possible redirect loop detected.`,\n },\n },\n redirectUrl: \"\",\n };\n }\n\n // Extract and validate redirect URL\n const redirectUrl = response.headers.get('location');\n if (!redirectUrl) {\n return {\n error: {\n status: 502,\n error: {\n code: \"InvalidRedirect\",\n message: \"302 redirect response missing Location header\",\n },\n },\n redirectUrl: \"\",\n };\n }\n\n // Validate redirect URL for security\n try {\n this.validateRedirectUrlSecurity(redirectUrl);\n } catch (error) {\n return {\n error: {\n status: 502,\n error: {\n code: \"InvalidRedirectUrl\",\n message: error instanceof Error ? error.message : \"Invalid redirect URL\",\n },\n },\n redirectUrl: \"\",\n };\n }\n\n // All validations passed\n return { redirectUrl };\n }\n\n /**\n * Validates that a redirect URL is secure and targets a trusted APIM Bentley domain.\n *\n * This method enforces security requirements for following HTTP redirects:\n * - URL must use HTTPS protocol (not HTTP)\n * - Domain must be a Bentley-owned domain (*api.bentley.com)\n *\n * @param url - The redirect URL to validate\n * @returns True if the URL is valid and safe to follow\n * @throws Error if the URL is invalid, uses HTTP, or targets an untrusted domain\n *\n * @remarks\n * This validation is critical for security when following 302 redirects in federated\n * architecture scenarios. It prevents redirect attacks that could leak authentication\n * credentials to malicious domains.\n *\n * @example\n * ```typescript\n * // Valid URLs\n * this.validateRedirectUrl(\"https://api.bentley.com/resource\");\n * // Invalid URLs (will throw)\n * this.validateRedirectUrl(\"https://evil-tuna.com/phishing/\"); // Non-Bentley domain\n * this.validateRedirectUrl(\"https://bentley.com.evil.com/fake\"); // Domain spoofing attempt\n * ```\n */\n private validateRedirectUrlSecurity(url: string): boolean {\n let parsedUrl: URL;\n\n try {\n parsedUrl = new URL(url);\n } catch {\n throw new Error(`Invalid redirect URL: malformed URL \"${url}\"`);\n }\n\n // Require HTTPS protocol for security\n if (parsedUrl.protocol !== \"https:\") {\n throw new Error(\n `Invalid redirect URL: HTTPS required, but URL uses \"${parsedUrl.protocol}\" protocol. URL: ${url}`\n );\n }\n\n // Validate domain is a Bentley-owned domain (specific whitelist)\n const hostname = parsedUrl.hostname.toLowerCase();\n const allowedDomains = [\n \"api.bentley.com\",\n ];\n\n const isBentleyDomain = allowedDomains.some(domain =>\n hostname === domain || hostname.endsWith(`-${domain}`)\n );\n\n if (!isBentleyDomain) {\n throw new Error(\n `Invalid redirect URL: domain \"${hostname}\" is not a trusted Bentley domain. Only api.bentley.com and its subdomains are allowed.`\n );\n }\n\n return true;\n }\n\n /**\n * Creates request configuration options with authentication headers.\n * Validates required parameters and sets up proper content type for JSON requests.\n *\n * @param accessTokenString - The client access token string for authorization\n * @param method - The HTTP method type (GET, POST, DELETE, etc.)\n * @param url - The complete URL of the request endpoint\n * @param data - Optional payload data to be JSON stringified for the request body\n * @param headers - Optional additional request headers to include\n * @returns RequestConfig object with method, URL, body, and headers configured\n * @throws Will throw an error if access token or URL are missing/invalid\n */\n protected createRequestOptions<TData>(\n accessTokenString: string,\n method: Method,\n url: string,\n data?: TData,\n headers: Record<string, string> = {}\n ): RequestConfig {\n if (!accessTokenString) {\n throw new Error(\"Access token is required\");\n }\n\n if (!url) {\n throw new Error(\"URL is required\");\n }\n let body: string | Blob | undefined;\n if (!(data instanceof Blob)) {\n body = JSON.stringify(data);\n } else {\n body = data;\n }\n return {\n method,\n url,\n body,\n headers: {\n ...headers,\n authorization: accessTokenString,\n \"content-type\":\n headers.contentType || headers[\"content-type\"]\n ? headers.contentType || headers[\"content-type\"]\n : \"application/json\",\n },\n };\n }\n\n /**\n * Builds a query string to be appended to a URL from query arguments\n * @param parameterMapping - Parameter mapping configuration that maps object properties to query parameter names\n * @param queryArg - Object containing queryable properties for filtering\n * @returns Query string with parameters applied, ready to append to a URL\n *\n * @example\n * ```typescript\n * const queryString = this.getQueryStringArg(\n * ITwinsAccess.ITWINS_QUERY_PARAM_MAPPING,\n * {\n * search: \"Building A\",\n * top: 10,\n * subClass: \"Asset\"\n * }\n * );\n * // Returns: \"$search=Building%20A&$top=10&subClass=Asset\"\n * ```\n */\n protected getQueryStringArg<T>(\n parameterMapping: ParameterMapping<NonNullable<T>>,\n queryArg?: T\n ): string {\n if (!queryArg) return \"\";\n\n const params = this.buildQueryParams(queryArg, parameterMapping);\n return params.join(\"&\");\n }\n\n /**\n * Helper method to build query parameter array from mapping.\n * Uses exhaustive parameter mapping to ensure type safety and prevent missing parameters.\n * Automatically handles URL encoding and filters out excluded parameters.\n *\n * @param queryArg - Object containing queryable properties\n * @param mapping - Parameter mapping configuration that maps object properties to query parameter names\n * @returns Array of formatted query parameter strings ready for URL construction\n *\n * @example\n * ```typescript\n * const params = this.buildQueryParams(\n * { search: \"Building A\", top: 10 },\n * { search: \"$search\", top: \"$top\" }\n * );\n * // Returns: [\"$search=Building%20A\", \"$top=10\"]\n * ```\n */\n private buildQueryParams<T>(\n queryArg: T,\n mapping: ParameterMapping<T>\n ): string[] {\n const params: string[] = [];\n // Type assertion constrains paramKey to actual property names and mappedValue to the specific strings from the mapping\n // Narrows from set of all strings to only valid keys/values\n for (const [paramKey, mappedValue] of Object.entries(mapping) as [\n keyof T,\n ParameterMapping<T>[keyof T]\n ][]) {\n if (mappedValue === \"\") continue;\n const queryArgValue = queryArg[paramKey];\n if (queryArgValue !== undefined && queryArgValue !== null) {\n const stringValue = String(queryArgValue);\n params.push(`${mappedValue}=${encodeURIComponent(stringValue)}`);\n }\n }\n return params;\n }\n}\n", "/*---------------------------------------------------------------------------------------------\n *