@rschili/melodi-cli
Version:
iModel utility
1,433 lines (1,410 loc) • 76.1 kB
JavaScript
#!/usr/bin/env node
// src/Workspace.ts
import * as fs from "fs";
import path from "path";
import os from "os";
import { z } from "zod/v4";
import { globby } from "globby";
import { SQLiteDb } from "@itwin/core-backend";
import { DbResult, OpenMode } from "@itwin/core-bentley";
// src/ConsoleHelper.ts
import { log } from "@clack/prompts";
import chalk from "chalk";
var formatPath = chalk.blueBright.underline;
var formatError = chalk.redBright.bold;
var formatWarning = chalk.yellowBright;
var formatSuccess = chalk.greenBright.bold;
var resetChar = "\x1B[0m";
function printError(error, printAsWarning = false) {
const formatter = printAsWarning ? formatWarning : formatError;
const label = printAsWarning ? "Warning" : "Error";
if (error instanceof Error) {
console.error(formatter(`${label}: ${error.message}`));
} else {
console.error(formatter(`${label}: ${String(error)}`));
}
}
function logError(error) {
if (error instanceof Error) {
log.error(error.message);
} else {
log.error(`Error: ${String(error)}`);
}
}
function generateColorizerMap(values) {
const colorizerMap = /* @__PURE__ */ new Map();
const colors = [
chalk.redBright,
chalk.greenBright,
chalk.blueBright,
chalk.yellowBright,
chalk.cyanBright,
chalk.magentaBright,
chalk.whiteBright
];
const uniqueValues = Array.from(new Set(values));
uniqueValues.forEach((value, index) => {
const color = colors[index % colors.length];
colorizerMap.set(value, color);
});
return colorizerMap;
}
var msInSecond = 1e3;
var msInMinute = msInSecond * 60;
var msInHour = msInMinute * 60;
var msInDay = msInHour * 24;
var msInYear = msInDay * 365.25;
function timeSpanToString(span) {
if (span > msInYear * 100 || span <= 0) {
return void 0;
}
if (span < msInMinute) {
const seconds = Math.floor(span / msInSecond);
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
} else if (span < msInHour) {
const minutes = Math.floor(span / msInMinute);
return `${minutes} minute${minutes !== 1 ? "s" : ""}`;
} else if (span < msInDay) {
const hours = Math.floor(span / msInHour);
return `${hours} hour${hours !== 1 ? "s" : ""}`;
} else if (span < msInYear) {
const days = Math.floor(span / msInDay);
return `${days} day${days !== 1 ? "s" : ""}`;
} else {
const years = Math.floor(span / msInYear);
return `${years} year${years !== 1 ? "s" : ""}`;
}
}
// src/Workspace.ts
import { SemVer } from "semver";
// src/buildInfo.ts
var __BUILD_DATE__ = "2025-07-09T08:03:37Z";
// package.json
var package_default = {
name: "@rschili/melodi-cli",
version: "1.3.1",
description: "iModel utility",
main: "dist/index.mjs",
type: "module",
engines: {
node: ">=22.14.0"
},
scripts: {
typecheck: "tsc --noEmit",
build: "node esbuild.config.mjs",
test: "vitest",
start: "node dist/index.mjs",
lint: "eslint 'src/**/*.ts' --fix",
prebuild: `echo "export const __BUILD_DATE__ = '$(date -u +%Y-%m-%dT%H:%M:%SZ)';" > src/buildInfo.ts && npm run lint && npm run typecheck`
},
bin: {
melodi: "dist/index.mjs"
},
publishConfig: {
access: "public"
},
files: [
"dist",
"LICENSE",
"README.md",
"CHANGELOG.md"
],
repository: {
type: "git",
url: "git+https://github.com/rschili/melodi-cli.git"
},
keywords: [
"itwin",
"imodel",
"bentley",
"ecdb",
"bim"
],
author: "Robert Schili",
license: "MIT",
bugs: {
url: "https://github.com/rschili/melodi-cli/issues"
},
homepage: "https://github.com/rschili/melodi-cli#readme",
devDependencies: {
"@eslint/js": "^9.30.1",
"@types/node": "^24.0.10",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",
esbuild: "^0.25.6",
"esbuild-node-externals": "^1.18.0",
eslint: "^9.30.1",
globals: "^16.3.0",
typescript: "^5.8.3",
"typescript-eslint": "^8.36.0",
vitest: "^3.2.4"
},
dependencies: {
"@clack/prompts": "^0.11.0",
"@itwin/core-backend": "^5.0.2",
"@itwin/core-bentley": "^5.0.2",
"@itwin/core-common": "^5.0.2",
"@itwin/ecschema-metadata": "^5.0.2",
"@itwin/imodels-access-backend": "^6.0.1",
"@itwin/imodels-access-common": "^6.0.1",
"@itwin/imodels-client-authoring": "^6.0.1",
"@itwin/itwins-client": "^1.6.1",
"@itwin/node-cli-authorization": "^3.0.1",
"@itwin/object-storage-azure": "^3.0.1",
"@itwin/object-storage-core": "^3.0.1",
"@itwin/object-storage-google": "^3.0.1",
"@types/semver": "^7.7.0",
axios: "^1.10.0",
chalk: "^5.4.1",
emphasize: "^7.0.0",
globby: "^14.1.0",
"gradient-string": "^3.0.0",
"module-alias": "^2.2.3",
semver: "^7.7.2",
"simple-update-notifier": "^2.0.0",
table: "^6.9.0",
zod: "^3.25.76"
},
overrides: {
inversify: "7.5.2",
"reflect-metadata": "^0.2.2"
}
};
// src/Diagnostics.ts
import updateNotifier from "simple-update-notifier";
var applicationVersion = package_default.version;
var applicationBuildDate = new Date(__BUILD_DATE__).toLocaleString();
async function checkUpdates() {
await updateNotifier({ pkg: package_default });
}
// src/EnvironmentManager.ts
import { select } from "@clack/prompts";
import { IModelHost } from "@itwin/core-backend";
import { IModelsClient } from "@itwin/imodels-client-authoring";
import { ITwinsAccessClient } from "@itwin/itwins-client";
import { NodeCliAuthorizationClient } from "@itwin/node-cli-authorization";
import { AzureClientStorage, BlockBlobClientWrapperFactory } from "@itwin/object-storage-azure";
import { StrategyClientStorage } from "@itwin/object-storage-core";
import { GoogleClientStorage } from "@itwin/object-storage-google/lib/client/index.js";
import { ClientStorageWrapperFactory } from "@itwin/object-storage-google/lib/client/wrappers/index.js";
import { AccessTokenAdapter } from "@itwin/imodels-access-common";
import { BackendIModelsAccess } from "@itwin/imodels-access-backend";
var EnvironmentManager = class {
_cacheDir;
_currentEnvironment = "PROD" /* PROD */;
_authClient;
_iModelsClient;
_iTwinsClient;
_isSignedIn = false;
_isStartedUp = false;
constructor(cacheDir) {
this._cacheDir = cacheDir;
}
get currentEnvironment() {
return this._currentEnvironment;
}
get cacheDirectory() {
return this._cacheDir;
}
async selectEnvironment(newEnvironment) {
if (newEnvironment !== this._currentEnvironment) {
await this.shutdown();
this._currentEnvironment = newEnvironment;
await this.startup();
}
}
async startup() {
if (this._isStartedUp) {
return;
}
const hubAccess = new BackendIModelsAccess(this.iModelsClient);
await IModelHost.startup({
cacheDir: this._cacheDir,
hubAccess,
authorizationClient: this.authClient
});
this._isStartedUp = true;
}
async shutdown() {
if (this._isStartedUp) {
this._authClient = void 0;
this._iModelsClient = void 0;
this._iTwinsClient = void 0;
this._isSignedIn = false;
this._isStartedUp = false;
await IModelHost.shutdown();
}
}
get authority() {
if (this._currentEnvironment === "PROD" /* PROD */) {
return "https://ims.bentley.com/";
}
if (this._currentEnvironment === "QA" /* QA */) {
return "https://qa-ims.bentley.com/";
}
throw new Error(`Unknown environment: ${this._currentEnvironment}`);
}
get clientId() {
switch (this._currentEnvironment) {
case "PROD" /* PROD */:
return "native-b517RwSFtag94aBZ5lM40QCf6";
case "QA" /* QA */:
return "native-jq2fZ8ZMoMjTKVDghCOpjY4JQ";
default:
throw new Error(`Unknown environment: ${this._currentEnvironment}`);
}
}
async getAccessToken() {
if (this._authClient === void 0) {
throw new Error("Authorization client is not initialized. Call signInIfNecessary() first.");
}
const parts = (await this._authClient.getAccessToken()).split(" ");
return { scheme: parts[0], token: parts[1] };
}
async getAuthorization() {
if (this._authClient === void 0) {
throw new Error("Authorization client is not initialized. Call signInIfNecessary() first.");
}
return AccessTokenAdapter.toAuthorization(await this._authClient.getAccessToken());
}
get authClient() {
if (!this._authClient) {
this._authClient = new NodeCliAuthorizationClient({
issuerUrl: this.authority,
clientId: this.clientId,
redirectUri: "http://localhost:3000/signin-callback",
scope: "itwin-platform"
});
}
return this._authClient;
}
async signInIfNecessary() {
if (!this._isSignedIn) {
await this.authClient.signIn();
}
}
get iModelsClient() {
if (!this._iModelsClient) {
const iModelsClientOptions = {
cloudStorage: new StrategyClientStorage([
{
instanceName: "azure",
instance: new AzureClientStorage(new BlockBlobClientWrapperFactory())
},
{
instanceName: "google",
instance: new GoogleClientStorage(new ClientStorageWrapperFactory())
}
]),
api: this._currentEnvironment === "QA" /* QA */ ? { baseUrl: "https://qa-api.bentley.com/imodels" } : void 0
};
this._iModelsClient = new IModelsClient(iModelsClientOptions);
}
return this._iModelsClient;
}
get iTwinsClient() {
if (!this._iTwinsClient) {
this._iTwinsClient = new ITwinsAccessClient(this._currentEnvironment === "QA" /* QA */ ? "https://qa-api.bentley.com/itwins" : void 0);
}
return this._iTwinsClient;
}
async promptEnvironment() {
return await select({
message: "Select an environment",
options: [
{ label: "PROD", value: "PROD" /* PROD */ },
{ label: "QA", value: "QA" /* QA */ }
],
initialValue: this._currentEnvironment
});
}
};
// src/Workspace.ts
var WorkspaceConfigSchema = z.object({
melodiVersion: z.string(),
ecsqlHistory: z.array(z.string()).optional()
});
var MelodiConfigFolderName = ".melodi";
var CacheFolderName = ".itwinjs-cache";
var ConfigFileName = "config.json";
async function loadWorkspace(userConfig, root = process.cwd()) {
const workspaceRootPath = root;
const userConfigDirPath = path.join(os.homedir(), MelodiConfigFolderName);
const melodiConfigPath = path.join(workspaceRootPath, MelodiConfigFolderName);
const cacheDirPath = path.join(workspaceRootPath, CacheFolderName);
if (!fs.existsSync(workspaceRootPath) || !fs.lstatSync(workspaceRootPath).isDirectory()) {
throw new Error(`The current working directory is not a valid directory: ${workspaceRootPath}`);
}
try {
fs.accessSync(workspaceRootPath, fs.constants.R_OK | fs.constants.W_OK);
} catch {
throw new Error(`The current working directory is not accessible: ${workspaceRootPath}. Please check permissions.`);
}
const configPath = path.join(melodiConfigPath, ConfigFileName);
const environment = new EnvironmentManager(cacheDirPath);
if (!fs.existsSync(configPath)) {
return {
workspaceRootPath,
workspaceConfigDirPath: melodiConfigPath,
envManager: environment,
userConfig
};
}
const config = await readWorkspaceConfig(configPath);
if (!fs.existsSync(userConfigDirPath)) {
await fs.promises.mkdir(userConfigDirPath, { recursive: true });
}
return {
workspaceRootPath,
workspaceConfigDirPath: melodiConfigPath,
config,
envManager: environment,
userConfig
};
}
async function readWorkspaceConfig(configPath) {
const data = await fs.promises.readFile(configPath, "utf-8");
const json = JSON.parse(data);
return await WorkspaceConfigSchema.parseAsync(json);
}
async function saveWorkspaceConfig(ws) {
if (ws.config === void 0) {
throw new Error("Workspace config is undefined. Please provide a valid config.");
}
if (!fs.existsSync(ws.workspaceConfigDirPath)) {
await fs.promises.mkdir(ws.workspaceConfigDirPath, { recursive: true });
}
if (!fs.lstatSync(ws.workspaceConfigDirPath).isDirectory()) {
throw new Error(`The workspace config directory is not a valid directory: ${ws.workspaceConfigDirPath}`);
}
try {
fs.accessSync(ws.workspaceConfigDirPath, fs.constants.R_OK | fs.constants.W_OK);
} catch {
throw new Error(`The workspace config directory is not accessible: ${ws.workspaceConfigDirPath}. Please check permissions.`);
}
const configPath = path.join(ws.workspaceConfigDirPath, ConfigFileName);
ws.config.melodiVersion = applicationVersion;
const data = JSON.stringify(ws.config, void 0, 2);
await fs.promises.writeFile(configPath, data, "utf-8");
}
async function detectWorkspaceFiles(ws) {
const patterns = [
"**/*.bim",
"**/*.ecdb"
];
const ignore = [
"**/.*",
// Ignore dotfiles and dotfolders
"**/.*/**"
// Also ignore anything inside dotfolders
];
const files = await globby(patterns, {
cwd: ws.workspaceRootPath,
absolute: false,
deep: 2,
// Limit to 2 levels deep
dot: false,
// Don't match dotfiles/folders
ignore,
caseSensitiveMatch: false
});
const workspaceFiles = files.map((file) => {
const absolutePath = path.join(ws.workspaceRootPath, file);
const stats = fs.statSync(absolutePath);
const lastTouched = new Date(Math.max(stats.mtime.getTime(), stats.birthtime.getTime(), stats.ctime.getTime()));
return {
relativePath: file,
lastTouched
};
});
await readFileProps(ws, workspaceFiles);
ws.files = workspaceFiles;
}
function getFileContextFolderPath(root, relativeFilePath) {
const parsed = path.parse(relativeFilePath);
const contextFolderName = `${parsed.name}_extras`;
return path.join(root, parsed.dir, contextFolderName);
}
var schemaVersionSchema = z.object({
major: z.number(),
minor: z.number(),
sub1: z.number(),
sub2: z.number()
});
async function readFileProps(ws, files) {
if (files.length === 0) {
return;
}
const db = new SQLiteDb();
for (const file of files) {
try {
const absolutePath = path.join(ws.workspaceRootPath, file.relativePath);
db.openDb(absolutePath, OpenMode.Readonly);
db.withPreparedSqliteStatement("SELECT Name, Val FROM be_Local", (stmt) => {
while (stmt.step() === DbResult.BE_SQLITE_ROW) {
const name = stmt.getValueString(0);
if (name === "ParentChangeSetId") {
file.parentChangeSetId = stmt.getValueString(1);
}
}
});
db.withPreparedSqliteStatement("SELECT Namespace, StrData FROM be_Prop WHERE Name = ?", (stmt) => {
stmt.bindString(1, "SchemaVersion");
while (stmt.step() === DbResult.BE_SQLITE_ROW) {
const namespace = stmt.getValueString(0);
const schemaVersion = stmt.getValueString(1);
const parsedSchemaVersion = schemaVersionSchema.safeParse(JSON.parse(schemaVersion));
if (parsedSchemaVersion.success) {
switch (namespace.toLowerCase()) {
case "be_db":
file.beDbVersion = parsedSchemaVersion.data;
break;
case "ec_db":
file.ecDbVersion = parsedSchemaVersion.data;
break;
case "dgn_db":
file.dgn_DbVersion = parsedSchemaVersion.data;
break;
default:
console.warn(`Unknown schema version namespace: ${namespace}. This may not be supported by melodi.`);
break;
}
}
}
});
db.withPreparedSqliteStatement("SELECT VersionDigit1, VersionDigit2, VersionDigit3 from ec_Schema WHERE Name = ?", (stmt) => {
stmt.bindString(1, "BisCore");
if (stmt.step() === DbResult.BE_SQLITE_ROW) {
const major2 = stmt.getValueInteger(0);
const minor2 = stmt.getValueInteger(1);
const sub1 = stmt.getValueInteger(2);
file.bisCoreVersion = new SemVer(`${major2}.${minor2}.${sub1}`);
}
});
if (file.bisCoreVersion !== void 0) {
db.withPreparedSqliteStatement("SELECT COUNT(*) FROM bis_Element", (stmt) => {
if (stmt.step() === DbResult.BE_SQLITE_ROW) {
file.elements = stmt.getValueInteger(0);
}
});
}
db.closeDb();
} catch (error) {
printError(error, true);
} finally {
if (db.isOpen) {
db.closeDb();
}
}
}
}
// src/Logic/NewFile.ts
import { log as log7, select as select6, text, isCancel as isCancel5, tasks, spinner, confirm as confirm2 } from "@clack/prompts";
import { ITwinSubClass } from "@itwin/itwins-client";
import chalk5 from "chalk";
import { Guid } from "@itwin/core-bentley";
import { existsSync as existsSync5 } from "node:fs";
// src/UnifiedDb.ts
import { select as select2, isCancel, log as log3 } from "@clack/prompts";
import { BriefcaseDb, ECDb, ECDbOpenMode, IModelDb, SQLiteDb as SQLiteDb2, StandaloneDb } from "@itwin/core-backend";
import { OpenMode as OpenMode2 } from "@itwin/core-bentley";
// src/IModelConfig.ts
import * as fs2 from "fs";
import path2 from "path";
import { z as z2 } from "zod/v4";
import { log as log2 } from "@clack/prompts";
var IModelConfigSchema = z2.object({
melodiVersion: z2.string(),
iModelId: z2.string(),
iTwinId: z2.string().optional(),
environment: z2.enum(["PROD" /* PROD */, "QA" /* QA */]),
displayName: z2.string()
});
var IModelConfigFileName = "config.json";
async function readIModelConfig(ws, iModelRelativePath) {
try {
const fileContextDir = getFileContextFolderPath(ws.workspaceRootPath, iModelRelativePath);
const configPath = path2.join(fileContextDir, IModelConfigFileName);
if (!fs2.existsSync(configPath)) {
return void 0;
}
const data = await fs2.promises.readFile(configPath, "utf-8");
const json = JSON.parse(data);
return await IModelConfigSchema.parseAsync(json);
} catch (err) {
log2.error("Failed to read iModel config.");
logError(err);
}
return void 0;
}
async function saveIModelConfig(ws, iModelRelativePath, cfg) {
const fileContextDir = getFileContextFolderPath(ws.workspaceRootPath, iModelRelativePath);
const configPath = path2.join(fileContextDir, IModelConfigFileName);
if (!fs2.existsSync(fileContextDir)) {
await fs2.promises.mkdir(fileContextDir, { recursive: true });
}
if (!fs2.lstatSync(fileContextDir).isDirectory()) {
throw new Error(`The iModel config directory is not a valid directory: ${fileContextDir}`);
}
try {
fs2.accessSync(fileContextDir, fs2.constants.R_OK | fs2.constants.W_OK);
} catch {
throw new Error(`The iModel config directory is not accessible: ${fileContextDir}. Please check permissions.`);
}
cfg.melodiVersion = applicationVersion;
const data = JSON.stringify(cfg, void 0, 2);
await fs2.promises.writeFile(configPath, data, "utf-8");
}
// src/UnifiedDb.ts
import path3 from "path";
var UnifiedDb = class {
db;
iModelConfig;
get innerDb() {
return this.db;
}
get config() {
return this.iModelConfig;
}
constructor(dbInstance, iModelConfig) {
this.db = dbInstance;
this.iModelConfig = iModelConfig;
}
get isOpen() {
if (this.db instanceof IModelDb) {
return this.db.isOpen;
}
if (this.db instanceof ECDb) {
return this.db.isOpen;
}
if (this.db instanceof SQLiteDb2) {
return this.db.isOpen;
}
throw new Error("Unsupported DB type for isOpen check.");
}
get supportsECSql() {
return !(this.db instanceof SQLiteDb2);
}
get isReadOnly() {
if (this.db instanceof IModelDb) {
return this.db.isReadonly;
}
if (this.db instanceof ECDb) {
return false;
}
if (this.db instanceof SQLiteDb2) {
return this.db.isReadonly;
}
throw new Error("Unsupported DB type for isReadOnly check.");
}
createQueryReader(ecsql, params, config) {
if (this.db instanceof IModelDb) {
return this.db.createQueryReader(ecsql, params, config);
}
if (this.db instanceof ECDb) {
return this.db.createQueryReader(ecsql, params, config);
}
throw new Error("ECSql is not supported by this DB type.");
}
withECSqlStatement(ecsql, callback, logErrors) {
if (!this.supportsECSql) {
throw new Error("ECSql statements are not supported by this DB type.");
}
if (this.db instanceof IModelDb) {
return this.db.withStatement(ecsql, callback, logErrors);
}
if (this.db instanceof ECDb) {
return this.db.withStatement(ecsql, callback, logErrors);
}
throw new Error("ECSql statements are not supported by this DB type.");
}
get supportsSchemas() {
return this.db instanceof IModelDb || this.db instanceof ECDb;
}
get supportsDumpSchemas() {
return this.db instanceof IModelDb;
}
async dumpSchemas(dir) {
if (this.db instanceof IModelDb) {
await this.db.exportSchemas(dir);
return;
}
throw new Error("Dumping schemas is not implemented by this DB type (native addon wants at least DgnDb for this). Try StandaloneDb.");
}
get supportsChangesets() {
return this.db instanceof BriefcaseDb;
}
[Symbol.dispose]() {
if (this.db instanceof IModelDb) {
if (this.db.isOpen) {
this.db.close();
}
return;
}
if (this.db instanceof ECDb) {
if (this.db.isOpen) {
this.db.closeDb();
}
this.db[Symbol.dispose]();
return;
}
if (this.db instanceof SQLiteDb2) {
if (this.db.isOpen) {
this.db.closeDb();
}
return;
}
}
};
async function openECDb(path11) {
const mode = await promptECDbOpenMode();
if (isCancel(mode)) {
return mode;
}
const db = new ECDb();
db.openDb(path11, mode);
return new UnifiedDb(db);
}
function createECDb(path11) {
const db = new ECDb();
db.createDb(path11);
return new UnifiedDb(db);
}
async function openStandaloneDb(path11) {
const mode = await promptOpenMode();
if (isCancel(mode)) {
return mode;
}
const db = StandaloneDb.openFile(path11, mode);
return new UnifiedDb(db);
}
function createStandaloneDb(path11, rootSubject) {
const db = StandaloneDb.createEmpty(path11, { rootSubject: { name: rootSubject } });
return new UnifiedDb(db);
}
async function openBriefcaseDb(workspace, file) {
const config = await readIModelConfig(workspace, file.relativePath);
if (config === void 0) {
throw new Error(`No iModel config found for file ${file.relativePath}. This file should exist for pulled imodels.`);
}
const absolutePath = path3.join(workspace.workspaceRootPath, file.relativePath);
await workspace.envManager.selectEnvironment(config.environment);
await workspace.envManager.signInIfNecessary();
const mode = await promptOpenMode();
const db = await BriefcaseDb.open({ fileName: absolutePath, key: config.iModelId, readonly: mode === OpenMode2.Readonly });
if (db.iModelId !== config.iModelId) {
log3.warn(`The iModel ID in the config (${config.iModelId}) does not match the opened iModel ID (${db.iModelId}). This may indicate a mismatch between the config and the file.`);
}
return new UnifiedDb(db);
}
async function promptECDbOpenMode() {
return await select2({
message: "Select the open mode for the file",
options: [
{ label: "Open in read-only mode", value: ECDbOpenMode.Readonly },
{ label: "Open in read-write mode", value: ECDbOpenMode.ReadWrite },
{ label: "Open the file in read-write mode and upgrade it to the latest file format version if necessary.", value: ECDbOpenMode.FileUpgrade }
]
});
}
async function promptOpenMode() {
return await select2({
message: "Select the open mode for the file",
options: [
{ label: "Open in read-only mode", value: OpenMode2.Readonly },
{ label: "Open in read-write mode", value: OpenMode2.ReadWrite }
]
});
}
// src/Logic/DbEditor.ts
import { QueryBinder, QueryOptionsBuilder as QueryOptionsBuilder2, QueryRowFormat as QueryRowFormat2 } from "@itwin/core-common";
import chalk4 from "chalk";
import { stdin, stdout } from "node:process";
import { createInterface } from "node:readline/promises";
import { table as table2 } from "table";
import { common, createEmphasize } from "emphasize";
import { performance } from "node:perf_hooks";
import { log as log6, select as select5, isCancel as isCancel4 } from "@clack/prompts";
// src/Logic/SchemaEditor.ts
import { QueryOptionsBuilder, QueryRowFormat } from "@itwin/core-common";
import chalk2 from "chalk";
import semver from "semver";
import { table } from "table";
// src/GithubBisSchemasHelper.ts
import z3 from "zod/v4";
import fs3 from "fs/promises";
import axios from "axios";
import path4 from "path";
var GithubBisSchemasRootUrl = "https://raw.githubusercontent.com/iTwin/bis-schemas/refs/heads/master/";
var SchemaInventoryPath = "SchemaInventory.json";
var ETagCacheFileName = "etag.json";
var SchemaInventorySchema = z3.record(z3.string(), z3.array(
z3.object({
name: z3.string(),
path: z3.string().optional(),
released: z3.boolean(),
version: z3.string()
})
));
var ETagCacheSchema = z3.record(z3.string(), z3.string());
async function loadSchemaInventory(cacheDirectory) {
const etagCacheFilePath = path4.join(cacheDirectory, ETagCacheFileName);
let etagCache = {};
await fs3.mkdir(cacheDirectory, { recursive: true });
if (await fileExists(etagCacheFilePath)) {
const fileContents = await fs3.readFile(etagCacheFilePath, "utf-8");
etagCache = ETagCacheSchema.parse(JSON.parse(fileContents));
}
const schemaInventory = await fetchUrl(GithubBisSchemasRootUrl, SchemaInventoryPath, cacheDirectory, etagCache);
await fs3.writeFile(etagCacheFilePath, JSON.stringify(etagCache, null, 2), "utf-8");
const parsedInventory = SchemaInventorySchema.parse(JSON.parse(schemaInventory));
return parsedInventory;
}
async function fetchUrl(rootUrl, subUrl, cacheDirectory, etagCache) {
const url = new URL(subUrl, rootUrl);
const cacheFilePath = path4.join(cacheDirectory, subUrl.replace(/\//g, "_"));
const etag = etagCache[url.href];
const options = { responseType: "text", validateStatus: (status) => status === 200 || status === 304 };
if (etag && await fileExists(cacheFilePath)) {
options.headers = { "If-None-Match": etag };
}
const response = await axios.get(url.href, options);
if (response.status === 304) {
const cachedContent = await fs3.readFile(cacheFilePath, "utf-8");
return cachedContent;
}
etagCache[url.href] = response.headers.etag;
await fs3.writeFile(cacheFilePath, response.data, "utf-8");
return response.data;
}
async function fileExists(filePath) {
try {
const stats = await fs3.stat(filePath);
return stats.isFile();
} catch {
return false;
}
}
// src/Logic/SchemaEditor.ts
import { log as log4, select as select3, isCancel as isCancel2 } from "@clack/prompts";
// src/Workspace.UserConfig.ts
import * as fs4 from "fs";
import path5 from "path";
import os2 from "os";
import { z as z4 } from "zod/v4";
var LogLevel = /* @__PURE__ */ ((LogLevel4) => {
LogLevel4[LogLevel4["Trace"] = 0] = "Trace";
LogLevel4[LogLevel4["Info"] = 1] = "Info";
LogLevel4[LogLevel4["Warning"] = 2] = "Warning";
LogLevel4[LogLevel4["Error"] = 3] = "Error";
LogLevel4[LogLevel4["None"] = 4] = "None";
return LogLevel4;
})(LogLevel || {});
var UserConfigSchema = z4.object({
melodiVersion: z4.string(),
logging: z4.enum(LogLevel).optional()
});
var AppDirectoryName = "melodi";
var WinConfigRelativePath = path5.join(AppDirectoryName, "config");
var WinCacheRelativePath = path5.join(AppDirectoryName, "cache");
var ConfigRelativePath = path5.join(".config", AppDirectoryName);
var CacheRelativePath = path5.join(".cache", AppDirectoryName);
var UserConfigFileName = "config.json";
async function readUserConfig() {
try {
const userConfigDir = getUserConfigDir();
const userConfigPath = path5.join(userConfigDir, UserConfigFileName);
if (fs4.existsSync(getUserConfigDir())) {
const data = await fs4.promises.readFile(userConfigPath, "utf-8");
const json = JSON.parse(data);
return await UserConfigSchema.parseAsync(json);
}
} catch (err) {
console.error(formatError("Failed to read user config. Using default config."));
printError(err);
}
return {
melodiVersion: applicationVersion,
logging: 4 /* None */
};
}
function getUserConfigDir() {
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
return path5.join(process.env.LOCALAPPDATA, WinConfigRelativePath);
}
if (process.env.XDG_CONFIG_HOME) {
return path5.join(process.env.XDG_CONFIG_HOME, AppDirectoryName);
}
return path5.join(os2.homedir(), ConfigRelativePath);
}
function getUserCacheDir() {
if (process.platform === "win32" && process.env.LOCALAPPDATA) {
return path5.join(process.env.LOCALAPPDATA, WinCacheRelativePath);
}
if (process.env.XDG_CACHE_HOME) {
return path5.join(process.env.XDG_CACHE_HOME, AppDirectoryName);
}
return path5.join(os2.homedir(), CacheRelativePath);
}
async function saveUserConfig(cfg) {
const userConfigDir = getUserConfigDir();
const userConfigPath = path5.join(userConfigDir, UserConfigFileName);
if (!fs4.existsSync(userConfigDir)) {
await fs4.promises.mkdir(userConfigDir, { recursive: true });
}
if (!fs4.lstatSync(userConfigDir).isDirectory()) {
throw new Error(`The user config directory is not a valid directory: ${userConfigDir}`);
}
try {
fs4.accessSync(userConfigDir, fs4.constants.R_OK | fs4.constants.W_OK);
} catch {
throw new Error(`The user config directory is not accessible: ${userConfigDir}. Please check permissions.`);
}
cfg.melodiVersion = applicationVersion;
const data = JSON.stringify(cfg, void 0, 2);
await fs4.promises.writeFile(userConfigPath, data, "utf-8");
}
// src/Logic/SchemaEditor.ts
import path6 from "node:path";
import { mkdirSync } from "node:fs";
var SchemaEditor = class {
static async run(ws, file, db) {
const queryOptions = new QueryOptionsBuilder();
queryOptions.setRowFormat(QueryRowFormat.UseECSqlPropertyIndexes);
const reader = db.createQueryReader(
"SELECT Name, VersionMajor ,VersionWrite, VersionMinor FROM meta.ECSchemaDef",
void 0,
queryOptions.getOptions()
);
const schemasInDb = await reader.toArray();
const availableSchemas = await loadSchemaInventory(getUserCacheDir());
const schemaInfoMap = {};
for (const row of schemasInDb) {
const name = row[0];
const versionString = `${row[1]}.${row[2]}.${row[3]}`;
const version = semver.parse(versionString);
if (!version) {
console.log(formatWarning(`Schema ${row[0]} has an invalid version: ${row[1]}`));
continue;
}
schemaInfoMap[name] = {
name,
version
};
}
for (const [outerName, schemaGroup] of Object.entries(availableSchemas)) {
for (const schema of schemaGroup) {
if (!schema.released || !schema.path)
continue;
if (schema.name !== outerName) {
console.log(formatWarning(`Schema name mismatch: expected ${outerName}, got ${schema.name}`));
continue;
}
const cleanedVersion = stripLeadingZeros(schema.version);
const version = semver.parse(cleanedVersion);
if (!version) {
console.log(formatWarning(`Schema ${schema.name} has an invalid version: ${schema.version}`));
continue;
}
const existingSchema = schemaInfoMap[schema.name];
if (existingSchema) {
if (!existingSchema.latestVersion || semver.lt(existingSchema.latestVersion, version)) {
existingSchema.latestVersion = version;
existingSchema.path = schema.path;
}
} else {
schemaInfoMap[schema.name] = {
name: schema.name,
latestVersion: version,
path: schema.path
};
}
}
}
const schemaTable = [
["Name", "Current Version", "Latest published Version"]
];
for (const schema of Object.values(schemaInfoMap)) {
if (!schema.version) {
continue;
}
let latestVersion = "";
if (schema.latestVersion) {
if (semver.eq(schema.version, schema.latestVersion)) {
latestVersion = chalk2.green(schema.latestVersion.toString() + "\n(github)");
} else if (semver.lt(schema.version, schema.latestVersion)) {
latestVersion = chalk2.yellowBright(schema.latestVersion.toString() + "\n(github)");
} else {
latestVersion = chalk2.magenta(schema.latestVersion.toString() + "\n(github)");
}
}
schemaTable.push([
schema.name,
schema.version.toString(),
latestVersion
]);
}
console.log(table(schemaTable));
const schemaOption = await select3({
message: "Select a schema to update",
options: [
{ label: "Import a schema", value: "__import__" },
{ label: "Import multiple schemas", value: "__import_multiple__" },
{ label: "Dump all schemas as XML", value: "__dump__" },
{ label: "(Back)", value: "__back__" }
]
});
if (isCancel2(schemaOption) || schemaOption === "__back__") {
return;
}
if (schemaOption === "__dump__") {
const currentTime = Math.floor((Date.now() - (/* @__PURE__ */ new Date("2020-01-01")).getTime()) / 1e3).toString(36);
const dumpPath = path6.join(getFileContextFolderPath(ws.workspaceRootPath, file.relativePath), `schemas_dump_${currentTime}`);
log4.info(`Dumping all schemas to: ${dumpPath}`);
mkdirSync(dumpPath, { recursive: true });
await db.dumpSchemas(dumpPath);
return;
}
}
};
function stripLeadingZeros(str) {
return str.replace(/(^|\.)0+(?=\d)/g, "$1");
}
// src/Logic/DbSettings.ts
import { select as select4, isCancel as isCancel3, confirm, log as log5 } from "@clack/prompts";
import { DbResult as DbResult2 } from "@itwin/core-bentley";
import chalk3 from "chalk";
var DbSettings = class {
static async getExperimentalFeaturesEnabled(db) {
if (!db.supportsECSql) {
return false;
}
let result = false;
db.withECSqlStatement("PRAGMA experimental_features_enabled", (stmt) => {
if (stmt.step() !== DbResult2.BE_SQLITE_ROW) {
log5.error("Failed to read experimental features enabled flag");
return;
}
result = stmt.getValue(0).getBoolean();
});
return result;
}
static async setExperimentalFeaturesEnabled(db, enabled) {
if (!db.supportsECSql) {
throw new Error("Experimental features can only be set for IModelDb or ECDb.");
}
db.withECSqlStatement(`PRAGMA experimental_features_enabled=${enabled ? "true" : "false"}`, (stmt) => {
const result = stmt.step();
if (result != DbResult2.BE_SQLITE_ROW) {
log5.error(`Failed to set experimental features enabled flag`);
}
});
}
static async run(db) {
while (true) {
const experimentalEnabled = await this.getExperimentalFeaturesEnabled(db);
const choice = await select4(
{
message: "Select a setting to change:",
options: [
{ label: `Experimental features enabled: ${experimentalEnabled ? chalk3.greenBright("true") : chalk3.redBright("false")})`, value: "experimental" },
{ label: "Back", value: "back" }
]
}
);
if (isCancel3(choice) || choice === "back") {
return;
}
if (choice === "experimental") {
const newExperimentalEnabled = await confirm({ message: "Enable experimental features?", initialValue: experimentalEnabled });
if (isCancel3(newExperimentalEnabled)) {
continue;
}
await this.setExperimentalFeaturesEnabled(db, newExperimentalEnabled);
}
}
}
};
// src/Logic/DbEditor.ts
var emphasize = createEmphasize(common);
var DbEditor = class {
static async run(ws, file, db) {
if (!db.isOpen) {
throw new Error(`Db failed to open: ${file.relativePath}`);
}
while (true) {
const experimentalEnabled = await DbSettings.getExperimentalFeaturesEnabled(db);
const operation = await select5({
message: `${file.relativePath}${db.isReadOnly ? " (read-only)" : ""}`,
options: [
...db.supportsECSql ? [{ label: "ECSql", value: "ECSql" }] : [],
/*{ label: "Sqlite", value: "Sqlite" },*/
/*{ label: "Check", value: "Check" },*/
...db.supportsSchemas ? [{ label: "Schemas", value: "Schemas" }] : [],
...db.supportsChangesets ? [{ label: "Changesets", value: "Changesets" }] : [],
{ label: `Settings (Experimental features enabled: ${experimentalEnabled ? chalk4.greenBright("true") : chalk4.redBright("false")})`, value: "Settings" },
{ label: "Close", value: "Close" }
]
});
if (operation === "Close" || isCancel4(operation)) {
return;
}
try {
switch (operation) {
case "ECSql":
console.log();
log6.message("ECSql editor. (press up/down for history, Ctrl+C to exit, use semicolon to end statement)");
console.log();
while (await this.runECSql(ws, db)) {
}
break;
case "Sqlite":
console.log("Sqlite operation selected.");
break;
case "Stats":
console.log("Stats operation selected.");
break;
case "Schemas":
await SchemaEditor.run(ws, file, db);
break;
case "Changesets":
console.log("Changesets operation selected.");
break;
case "Settings":
await DbSettings.run(db);
break;
}
} catch (error) {
logError(error);
}
}
}
static async getClassName(db, classIdHex, cache) {
const params = new QueryBinder();
params.bindId(1, classIdHex);
const reader = db.createQueryReader(
`SELECT Name FROM meta.ECClassDef WHERE ECInstanceId = ? LIMIT 1`,
params,
{ rowFormat: QueryRowFormat2.UseECSqlPropertyIndexes }
);
const rows = await reader.toArray();
if (rows.length === 0) {
cache[classIdHex] = "UnknownClass";
} else {
const className = rows[0][0];
cache[classIdHex] = className;
}
return cache[classIdHex];
}
static async runECSql(ws, db) {
const queryOptions = new QueryOptionsBuilder2();
queryOptions.setRowFormat(QueryRowFormat2.UseECSqlPropertyIndexes);
queryOptions.setLimit({ count: 101 });
queryOptions.setAbbreviateBlobs(true);
const history = ws.config?.ecsqlHistory ?? [];
const rl = createInterface({
input: stdin,
output: stdout,
terminal: true,
prompt: "ECSql> ",
history
});
let interrupted = false;
rl.on("SIGINT", () => {
interrupted = true;
rl.close();
console.log("\n");
});
let ecsql = "";
rl.prompt();
for await (const line of rl) {
if (ecsql === "")
ecsql = line;
else
ecsql += "\n" + line;
if (line.trim().endsWith(";")) {
rl.close();
break;
}
rl.prompt();
}
if (interrupted) {
return false;
}
const newLength = history.length;
if (newLength > 10) {
ws.config.ecsqlHistory = history.slice(10);
}
await saveWorkspaceConfig(ws);
let rows = [];
let metadata = [];
const classIdCache = {};
const startTicks = performance.now();
let queryDuration = 0;
try {
const reader = db.createQueryReader(ecsql, void 0, queryOptions.getOptions());
rows = await reader.toArray();
if (rows === void 0 || rows.length === 0) {
console.log("No rows returned.");
return true;
}
metadata = await reader.getMetaData();
if (metadata.length === 0) {
console.log("No metadata returned.");
return true;
}
queryDuration = performance.now() - startTicks;
} catch (error) {
console.error(formatWarning(`ECSql query failed: ${ecsql}`));
printError(error);
return true;
}
const output = [];
const headerRow = metadata.map((col) => col.name);
output.push(headerRow);
const jsonCells = [];
const maxRowIndex = rows.length > 100 ? 99 : rows.length - 1;
for (let colIndex = 0; colIndex < metadata.length; colIndex++) {
const colInfo = metadata[colIndex];
for (let rowIndex = 0; rowIndex <= maxRowIndex; rowIndex++) {
const row = rows[rowIndex];
const { value: cValue, detectedType } = await this.formatValue(row[colIndex], colInfo, db, classIdCache);
let value = cValue;
if (detectedType === "json") {
jsonCells.push({ rowIndex: rowIndex + 2, colIndex });
}
if (colIndex === 0) {
output.push(new Array(metadata.length));
}
if (value === null || value === void 0) {
value = "";
}
if (value !== null && value !== void 0) {
const formattedValue = String(value);
output[rowIndex + 1][colIndex] = formattedValue;
}
}
}
const widths = this.calculateColumnWidths(output, process.stdout.columns);
const columns = [];
for (let i = 0; i < output[0].length; i++) {
const meta = metadata[i];
const width = widths[i];
const isNumericType = meta.typeName === "int" || meta.typeName === "double" || meta.typeName === "long";
const alignment = isNumericType ? "right" : "left";
columns.push({ alignment, width, wrapWord: false });
}
const config = {
columns,
spanningCells: [
{ col: 0, row: 0, colSpan: output[0].length, alignment: "center" }
]
};
const formattedSql = emphasize.highlight("sql", ecsql).value;
for (let i = 0; i < metadata.length; i++) {
const value = output[0][i];
const meta = metadata[i];
const typeName = meta.extendedType ?? meta.typeName;
output[0][i] = `${chalk4.bold(value)}
${chalk4.italic(typeName)}`;
}
output.unshift([formattedSql, ...Array(headerRow.length - 1).fill("")]);
if (jsonCells.length > 0) {
for (const cell of jsonCells) {
const highlighted = emphasize.highlight("json", output[cell.rowIndex][cell.colIndex]);
if (highlighted.value) {
output[cell.rowIndex][cell.colIndex] = highlighted.value + resetChar;
}
}
}
console.log(table2(output, config));
if (rows.length > 100) {
console.log(formatWarning("More than 100 rows returned. Only the first 100 rows are displayed."));
}
if (queryDuration < 1e3) {
console.log(`Executed in ${queryDuration.toFixed()} ms.`);
} else {
console.log(`Executed in ${(queryDuration / 1e3).toFixed(2)} seconds.`);
}
return true;
}
static async formatValue(value, colInfo, db, classIdCache) {
if (value === null || value === void 0) {
return { value: "" };
}
if (typeof value === "string") {
try {
if (value && (colInfo.extendedType?.toLowerCase() === "json" || colInfo.typeName.toLowerCase() === "string") && value.startsWith("{")) {
const jsonValue = JSON.parse(value);
if (typeof jsonValue === "object" && jsonValue) {
return { value: JSON.stringify(jsonValue, null, 2), detectedType: "json" };
}
}
} catch {
}
return { value };
}
if (typeof value === "number" || typeof value === "boolean") {
return { value: String(value) };
}
if (colInfo.typeName === "navigation") {
if (typeof value === "object" && value !== null && "Id" in value && "RelECClassId" in value) {
const id = value.Id;
const classId = value.RelECClassId;
if (!id || !classId) {
return { value: "" };
}
const className = await this.getClassName(db, classId, classIdCache);
return { value: `${className} ${id}` };
}
return { value: "" };
}
if (Array.isArray(value)) {
return { value: `[${value.map((v) => this.formatValue(v, colInfo, db, classIdCache)).join(", ")}]` };
}
return { value: JSON.stringify(value) };
}
static calculateColumnWidths(data, maxWidth) {
if (data.length === 0) {
return [];
}
if (maxWidth < 80) maxWidth = 80;
let columnWidths = [];
const minWidthPerColumn = 8;
const minRequiredWidth = data[0].length * minWidthPerColumn;
if (maxWidth < minRequiredWidth) {
columnWidths = new Array(data[0].length).fill(minWidthPerColumn);
} else {
for (const row of data) {
for (let i = 0; i < row.length; i++) {
const cell = row[i];
if (!cell)
continue;
if (!columnWidths[i] || cell.length > columnWidths[i]) {
const width = this.calculateWidth(cell);
columnWidths[i] = width;
}
}
}
let totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + columnWidths.length * 3;
if (totalWidth > maxWidth) {
while (totalWidth > maxWidth) {
let maxColIdx = 0;
for (let i = 1; i < columnWidths.length; i++) {
if (columnWidths[i] > columnWidths[maxColIdx]) {
maxColIdx = i;
}
}
columnWidths[maxColIdx] = Math.max(8, Math.floor(columnWidths[maxColIdx] / 2));
totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + columnWidths.length * 3;
}
}
}
return columnWidths;
}
static calculateWidth(cell) {
if (cell.includes("\n")) {
return cell.split("\n").reduce((max, line) => Math.max(max, line.length), 0);
}
return cell.length;
}
};
// src/Logic/NewFile.ts
import fs6 from "node:fs/promises";
import { CheckpointManager, ProgressStatus } from "@itwin/core-backend";
import path8 from "path";
// src/Logic/Changesets.ts
import fs5 from "node:fs/promises";
import path7 from "path";
import { z as z5 } from "zod/v4";
import { ContainingChanges } from "@itwin/imodels-client-management";
import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "node:fs";
var ChangesetListSchema = z5.array(z5.object({
id: z5.string(),
displayName: z5.string(),
index: z5.number(),
parentId: z5.string(),
pushDateTime: z5.string(),
containingChanges: z5.enum(ContainingChanges),
fileSize: z5.number(),
relativePath: z5.string().optional()
// path relative to the
}));
var Changesets = class _Changesets {
static getChangesetsFolder(ws, file) {
const contextFolder = getFileContextFolderPath(ws.workspaceRootPath, file.relativePath);
const changesetsDir = path7.join(contextFolder, "changesets");
return changesetsDir;
}
static getChangesetRelativePath(ws, file, absolutePath) {
const changesetsDir = this.getChangesetsFolder(ws, file);
if (!absolutePath.startsWith(changesetsDir)) {
throw new Error(`Absolute path ${absolutePath} does not start with changesets directory ${changesetsDir}`);
}
const relativePath = path7.relative(changesetsDir, absolutePath);
if (!relativePath) {
throw new Error(`Could not determine relative path for ${absolutePath} in changesets directory ${changesetsDir}`);
}
return relativePath;
}
static calculateOverallFileSize(changesets) {
return changesets.reduce((total, changeset) => total + (changeset.fileSize || 0), 0);
}
static downloadedChangesetsToChangesetList(ws, file, downloadedChangesets) {
const list = [];
for (const changeset of downloadedChangesets) {
const relativePath = this.getChangesetRelativePath(ws, file, changeset.filePath);
list.push({
id: changeset.id,
displayName: changeset.displayName,
index: changeset.index,
parentId: changeset.parentId,
pushDateTime: changeset.pushDateTime,
containingChanges: changeset.containingChanges,
fileSize: changeset.fileSize,
relativePath
});
}
return list;
}
static async readChangesetListFromFile(ws, file) {
const changesetsDir = this.getChangesetsFolder(ws, file);
const changesetListFile = path7.join(changesetsDir, "changeset-list.json");
if (!existsSync4(changesetListFile)) {
return [];
}
const content = await fs5.readFile(changesetListFile, "utf-8");
try {
return ChangesetListSchema.parse(JSON.parse(content));
} catch (error) {
throw new Error(`Failed to parse changeset list from file ${changesetListFile}: ${error}`);
}
}
static async writeChangesetListToFile(ws, file, changesetList) {
const changesetsDir = this.getChangesetsFolder(ws, file);
if (!existsSync4(changesetsDir)) {
mkdirSync2(changesetsDir, { recursive: true });
}
const changesetListFile = path7.join(changesetsDir, "changeset-list.json");
await fs5.writeFile(changesetListFile, JSON.stringify(changesetList, null, 2), "utf-8");
}
static async downloadChangesets(ws, file, iModelId) {
const changesetsDir = this.getChangesetsFolder(ws, file);
if (!existsSync4(changesetsDir)) {
mkdirSync2(changesetsDir, { recursive: true });
}
if (!existsSync4(changesetsDir)) {
await fs5.mkdir(changesetsDir, { recursive: true });
}
const downloaded = await ws.envManager.iModelsClient.changesets.downloadList({
authorization: () => ws.envManager.getAuthorization(),
iModelId,
targetDirectoryPath: changesetsDir
});
const cacheList = _Changesets.downloadedChangesetsToChangesetList(ws, file, downloaded);
await _Changesets.writeChangesetListToFile(ws, file, cacheList);
}
};
// src/Logic/NewFile.ts
var NewFile = class {
static async run(w