diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 881d0981..6fa78533 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -11,6 +11,10 @@ * [info](/docs/auth/info.md) * [login](/docs/auth/login.md) * [logout](/docs/auth/logout.md) +* [context](/docs/context/overview.md) + * [set](/docs/context/set.md) + * [clear](/docs/context/clear.md) + * [info](/docs/context/info.md) * [api](/docs/api.md) * [itwin](/docs/itwin/overview.md) * [create](/docs/itwin/create.md) diff --git a/docs/context/clear.md b/docs/context/clear.md new file mode 100644 index 00000000..e0c85467 --- /dev/null +++ b/docs/context/clear.md @@ -0,0 +1,10 @@ +# itp context clear + +Clear the cached context. + +## Examples + +```bash +# Example 1: Clear the cached context +itp context clear +``` diff --git a/docs/context/info.md b/docs/context/info.md new file mode 100644 index 00000000..866a73dc --- /dev/null +++ b/docs/context/info.md @@ -0,0 +1,10 @@ +# itp context info + +Display the cached context. + +## Examples + +```bash +# Example 1: Display the cached context +itp context info +``` diff --git a/docs/context/overview.md b/docs/context/overview.md new file mode 100644 index 00000000..dadcf8f7 --- /dev/null +++ b/docs/context/overview.md @@ -0,0 +1,9 @@ +# itp context + +Work with iTwin CLI context. + +## Available Commands + +- [itp context set](set.md) +- [itp context info](info.md) +- [itp context clear](clear.md) diff --git a/docs/context/set.md b/docs/context/set.md new file mode 100644 index 00000000..8354ef05 --- /dev/null +++ b/docs/context/set.md @@ -0,0 +1,26 @@ +# itp context set + +Set a new cached context. + +## Options + +- **`-m, --imodel-id`** + The ID of the iModel to create a context for. + **Type:** `string` **Required:** No + +- **`-i, --itwin-id`** + The ID of the iTwin to create a context for. + **Type:** `string` **Required:** No + +## Examples + +```bash +# Example 1: Set a new cached context using an iTwin ID +itp context set --itwin-id 12345 + +# Example 2: Set a new cached context using an iModel ID +itp context set --imodel-id 67890 + +# Example 3: Error when neither --itwin-id nor --imodel-id is provided +itp context set +``` diff --git a/integration-tests/context.ts/context.test.ts b/integration-tests/context.ts/context.test.ts new file mode 100644 index 00000000..bc182185 --- /dev/null +++ b/integration-tests/context.ts/context.test.ts @@ -0,0 +1,118 @@ +import { IModel } from "@itwin/imodels-client-management"; +import { ITwin } from "@itwin/itwins-client"; +import { runCommand } from "@oclif/test"; +import { expect } from "chai"; + +import { loginToCli } from "../utils/helpers"; + +describe('Context Integration Tests', () => { + let iTwin: ITwin; + let iModel: IModel; + let anotherITwin: ITwin; + + before(async () => { + await loginToCli(); + const name = `IntegrationTestITwin_${new Date().toISOString()}`; + const iTwinResult = await runCommand(`itwin create --name "${name}" --class Thing --sub-class Asset`); + expect(iTwinResult.error).to.be.undefined; + expect(iTwinResult.result).to.not.be.undefined; + iTwin = iTwinResult.result as ITwin; + + const iModelName = `IntegrationTestIModel_${new Date().toISOString()}`; + const iModelResult = await runCommand(`imodel create --name "${iModelName}" --itwin-id ${iTwin.id}`); + expect(iModelResult.error).to.be.undefined; + expect(iModelResult.result).to.not.be.undefined; + iModel = iModelResult.result as IModel; + + const anotherITwinName = `AnotherITwin_${new Date().toISOString()}`; + const anotherITwinResult = await runCommand(`itwin create --name "${anotherITwinName}" --class Thing --sub-class Asset`); + expect(anotherITwinResult.error).to.be.undefined; + expect(anotherITwinResult.result).to.not.be.undefined; + anotherITwin = anotherITwinResult.result as ITwin; + }); + + after(async () => { + await runCommand(`itwin delete --id ${iTwin.id}`); + await runCommand(`itwin delete --id ${anotherITwin.id}`); + }); + + beforeEach(async () => { + const output = await runCommand('context clear'); + expect(output.error).to.be.undefined; + }); + + it('should clear the context', async () => { + const output = await runCommand('context clear'); + expect(output.error).to.be.undefined; + expect(output.stdout).to.contain('Context cleared.'); + + const outputInfo = await runCommand('context info'); + expect(outputInfo.error).to.be.undefined; + expect(outputInfo.result).to.be.undefined; + }); + + it('should set the context', async () => { + const output = await runCommand(`context set --itwin-id ${iTwin.id} --imodel-id ${iModel.id}`); + expect(output.error).to.be.undefined; + expect(output.result).to.deep.equal({ iModelId: iModel.id, iTwinId: iTwin.id }); + }); + + it('should fail to set context with invalid iTwin ID', async () => { + const invalidITwinId = "invalid-id"; + const output = await runCommand(`context set --itwin-id ${invalidITwinId}`); + expect(output.error).to.not.be.undefined; + expect(output.error?.message).to.contain('Requested iTwin is not available.'); + }); + + it('should fail to set context with mismatched iModel and iTwin IDs', async () => { + const output = await runCommand(`context set --itwin-id ${anotherITwin.id} --imodel-id ${iModel.id}`); + expect(output.error).to.not.be.undefined; + expect(output.error?.message).to.contain(`The iModel ID ${iModel.id} does not belong to the specified iTwin ID ${anotherITwin.id}.`); + }); + + it('should fail to set context without iTwin or iModel ID', async () => { + const output = await runCommand('context set'); + expect(output.error).to.not.be.undefined; + expect(output.error?.message).to.contain('Either --itwin-id or --imodel-id must be provided.'); + }); + + it('should display the current context', async () => { + await runCommand(`context set --itwin-id ${iTwin.id} --imodel-id ${iModel.id}`); + const output = await runCommand('context info'); + expect(output.error).to.be.undefined; + expect(output.result).to.deep.equal({ iModelId: iModel.id, iTwinId: iTwin.id }); + }); + + it('should display undefined context after clearing', async () => { + await runCommand('context clear'); + const output = await runCommand('context info'); + expect(output.error).to.be.undefined; + expect(output.result).to.be.undefined; + }); + + it('should clear context multiple times without error', async () => { + await runCommand('context clear'); + const output = await runCommand('context clear'); + expect(output.error).to.be.undefined; + expect(output.stdout).to.contain('Context cleared.'); + }); + + it('should set the context with only an iTwin ID', async () => { + const output = await runCommand(`context set --itwin-id ${iTwin.id}`); + expect(output.error).to.be.undefined; + expect(output.result).to.deep.equal({ iModelId: undefined, iTwinId: iTwin.id }); + }); + + it('should set the context with only an iModel ID and resolve the correct iTwin ID', async () => { + const output = await runCommand(`context set --imodel-id ${iModel.id}`); + expect(output.error).to.be.undefined; + expect(output.result).to.deep.equal({ iModelId: iModel.id, iTwinId: iTwin.id }); + }); + + it('should fail to set context with only an iModel ID if the iModel does not exist', async () => { + const invalidIModelId = "invalid-id"; + const output = await runCommand(`context set --imodel-id ${invalidIModelId}`); + expect(output.error).to.not.be.undefined; + expect(output.error?.message).to.contain('Requested iModel is not available.'); + }); +}); \ No newline at end of file diff --git a/integration-tests/formating/formatting.test.ts b/integration-tests/formating/formatting.test.ts index 2ce2ad55..4f5ae93b 100644 --- a/integration-tests/formating/formatting.test.ts +++ b/integration-tests/formating/formatting.test.ts @@ -4,6 +4,11 @@ import { expect } from "chai"; describe('Command formatting tests', async () => { let allCommands : CommandWithFlags[] = []; + const commandToExcludeFromTests = [ + "help", + "plugins" + ]; + before(async () => { const config = await Config.load({ devPlugins: false, @@ -11,12 +16,13 @@ describe('Command formatting tests', async () => { userPlugins: false, }); - allCommands = config.commands.filter(command => !command.id.startsWith("plugins") && !command.id.startsWith("help") && !command.hidden) - .map((command) => - ({ - cmd: command, - flags: Object.entries(command.flags), - })); + allCommands = config.commands.filter(command => + !commandToExcludeFromTests.some(excluded => command.id.startsWith(excluded)) && !command.hidden + ).map((command) => + ({ + cmd: command, + flags: Object.entries(command.flags), + })); }); it('Should ensure all commands have a description', async () => { @@ -39,7 +45,8 @@ describe('Command formatting tests', async () => { }); it('Should ensure all itwin-id flags have env properties', async () => { - for (const command of allCommands) { + // Exclude context:set command from this test as it has a special case for itwin-id flag + for (const command of allCommands.filter(cmd => cmd.cmd.id !== "context:set")) { const iTwinIdFlag = command.flags.find(([name, _]) => name === "itwin-id"); if (iTwinIdFlag) { expect(iTwinIdFlag[1].env, `Flag 'itwin-id' in command '${command.cmd.id}' is missing the 'env' property`).to.be.a('string').and.be.equals("ITP_ITWIN_ID"); @@ -48,7 +55,8 @@ describe('Command formatting tests', async () => { }); it('Should ensure all imodel-id flags have env properties', async () => { - for (const command of allCommands) { + // Exclude context:set command from this test as it has a special case for imodel-id flag + for (const command of allCommands.filter(cmd => cmd.cmd.id !== "context:set")) { const iTwinIdFlag = command.flags.find(([name, _]) => name === "imodel-id"); if (iTwinIdFlag) { expect(iTwinIdFlag[1].env, `Flag 'imodel-id' in command '${command.cmd.id}' is missing the 'env' property`).to.be.a('string').and.be.equals("ITP_IMODEL_ID"); diff --git a/package-lock.json b/package-lock.json index 2e42f5cf..78d013cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@itwin/node-cli-authorization": "^2.0.3", "@itwin/object-storage-core": "^2.2.5", "@itwin/service-authorization": "^1.2.2", - "@oclif/core": "^4.2.8", + "@oclif/core": "^4.2.10", "@oclif/plugin-help": "^6", "@oclif/plugin-plugins": "^5", "@opentelemetry/api": "^1.9.0", diff --git a/package.json b/package.json index b433d7d5..0963c953 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@itwin/node-cli-authorization": "^2.0.3", "@itwin/object-storage-core": "^2.2.5", "@itwin/service-authorization": "^1.2.2", - "@oclif/core": "^4.2.8", + "@oclif/core": "^4.2.10", "@oclif/plugin-help": "^6", "@oclif/plugin-plugins": "^5", "@opentelemetry/api": "^1.9.0", diff --git a/src/commands/context/clear.ts b/src/commands/context/clear.ts new file mode 100644 index 00000000..fe14ea9d --- /dev/null +++ b/src/commands/context/clear.ts @@ -0,0 +1,17 @@ +import BaseCommand from "../../extensions/base-command.js"; + +export default class ClearContext extends BaseCommand { + static description = "Clear the cached context."; + + static examples = [ + { + command: `<%= config.bin %> <%= command.id %>`, + description: 'Example 1: Clear the cached context' + } + ]; + + async run() { + this.clearContext(); + return this.logAndReturnResult({ result: "Context cleared." }); + } +} \ No newline at end of file diff --git a/src/commands/context/info.ts b/src/commands/context/info.ts new file mode 100644 index 00000000..0f8abb81 --- /dev/null +++ b/src/commands/context/info.ts @@ -0,0 +1,17 @@ +import BaseCommand from "../../extensions/base-command.js"; + +export default class InfoContext extends BaseCommand { + static description = "Display the cached context."; + + static examples = [ + { + command: `<%= config.bin %> <%= command.id %>`, + description: 'Example 1: Display the cached context' + } + ]; + + async run() { + const context = this.getContext(); + return this.logAndReturnResult(context); + } +} \ No newline at end of file diff --git a/src/commands/context/set.ts b/src/commands/context/set.ts new file mode 100644 index 00000000..1740b8ba --- /dev/null +++ b/src/commands/context/set.ts @@ -0,0 +1,59 @@ +import { IModel } from "@itwin/imodels-client-management"; +import { ITwin } from "@itwin/itwins-client"; +import { Flags } from "@oclif/core"; + +import BaseCommand from "../../extensions/base-command.js"; + +export default class SetContext extends BaseCommand { + static description = "Set a new cached context."; + + static examples = [ + { + command: `<%= config.bin %> <%= command.id %>`, + description: 'Example 1: Set a new cached context' + } + ]; + + static flags = { + "imodel-id": Flags.string({ + char: 'm', + description: "The ID of the iModel to create a context for.", + helpValue: '', + required: false + }), + "itwin-id": Flags.string({ + char: 'i', + description: "The ID of the iTwin to create a context for.", + helpValue: '', + required: false, + }), + }; + + async run() { + const { flags } = await this.parse(SetContext); + const iModelId = flags["imodel-id"]; + let iTwinId = flags["itwin-id"]; + + // If iModelId is provided, check if it exists + // and verify that it belongs to the specified iTwinId + if(iModelId) { + const iModel = await this.runCommand("imodel:info", ["--imodel-id", iModelId]); + if(iTwinId && iModel.iTwinId !== flags["itwin-id"]) { + this.error(`The iModel ID ${iModelId} does not belong to the specified iTwin ID ${iTwinId}.`); + } + + iTwinId = iModel.iTwinId; + } + // If iTwinId is provided, check if it exists + else if (iTwinId) { + await this.runCommand("itwin:info", ["--itwin-id", iTwinId]); + } + // If neither iModelId nor iTwinId is provided, throw an error + else { + this.error("Either --itwin-id or --imodel-id must be provided."); + } + + const context = await this.setContext(iTwinId, iModelId); + return this.logAndReturnResult(context); + } +} \ No newline at end of file diff --git a/src/extensions/base-command.ts b/src/extensions/base-command.ts index 44d6405d..9898c928 100644 --- a/src/extensions/base-command.ts +++ b/src/extensions/base-command.ts @@ -6,15 +6,18 @@ import { Authorization, AuthorizationCallback, IModelsClient } from '@itwin/imodels-client-management'; import { ITwinsAccessClient } from '@itwin/itwins-client'; import { Command, Flags } from '@oclif/core'; +import { Input, ParserOutput } from '@oclif/core/interfaces'; import { Table } from 'console-table-printer'; import 'dotenv/config'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { ArgOutput, FlagOutput } from '../../node_modules/@oclif/core/lib/interfaces/parser.js'; import { AccessControlClient } from '../services/access-control-client/access-control-client.js'; import { AccessControlMemberClient } from '../services/access-control-client/access-control-member-client.js'; import { AuthorizationClient } from '../services/authorization-client/authorization-client.js'; import { ChangedElementsApiClient } from '../services/changed-elements-client/changed-elements-api-client.js'; +import { UserContext } from '../services/general-models/user-context.js'; import { ITwinPlatformApiClient } from '../services/iTwin-api-client.js'; import { StorageApiClient } from '../services/storage-client/storage-api-client.js'; import { SynchronizationApiClient } from '../services/synchronizationClient/synchronization-api-client.js'; @@ -45,6 +48,15 @@ export default abstract class BaseCommand extends Command { static enableJsonFlag = true; + protected async clearContext() { + const contextPath = this.config.cacheDir + '/context.json'; + if (fs.existsSync(contextPath)) { + fs.rmSync(contextPath, { force: true }); + } + + this.debug(`Cleared context file: ${contextPath}`); + } + protected async getAccessControlApiClient() { const token = await this.getAccessToken(); const url = this.getBaseApiUrl(); @@ -87,7 +99,6 @@ export default abstract class BaseCommand extends Command { return config?.apiUrl ?? 'https://api.bentley.com'; } - protected async getChangeElementApiClient() { return new ChangedElementsApiClient(await this.getITwinApiClient()); } @@ -127,6 +138,27 @@ export default abstract class BaseCommand extends Command { return config; } + protected getContext() : UserContext | undefined { + const contextPath = this.config.cacheDir + '/context.json'; + if (!fs.existsSync(contextPath)) { + return; + } + + try { + const contextFile = fs.readFileSync(contextPath, 'utf8'); + const context = JSON.parse(contextFile) as UserContext; + + if(!context.iModelId && !context.iTwinId) { + return undefined; + } + + return context; + } + catch (error) { + this.debug("Error parsing context file:", error); + } + } + protected getIModelClient() : IModelsClient { const baseUrl = `${this.getBaseApiUrl()}/imodels`; @@ -137,6 +169,12 @@ export default abstract class BaseCommand extends Command { }); } + protected getIModelId() : string | undefined { + const context = this.getContext(); + + return context?.iModelId; + } + protected getITwinAccessClient(): ITwinsAccessClient { const baseUrl = `${this.getBaseApiUrl()}/itwins`; return new ITwinsAccessClient(baseUrl); @@ -148,6 +186,12 @@ export default abstract class BaseCommand extends Command { return new ITwinPlatformApiClient(this.getBaseApiUrl(), token); } + protected getITwinId() : string | undefined { + const context = this.getContext(); + + return context?.iTwinId; + } + protected async getStorageApiClient() { return new StorageApiClient(await this.getITwinApiClient()); } @@ -193,8 +237,46 @@ export default abstract class BaseCommand extends Command { } } + protected override async parse( + options?: Input, + argv?: string[] + ): Promise> { + if(options?.flags) { + const context = this.getContext(); + + if (options.flags['itwin-id'] && !this.argv.includes('--itwin-id') && !this.argv.includes('-i') && context?.iTwinId) { + this.argv.push('--itwin-id', context.iTwinId); + } + + if (options.flags['imodel-id'] && !this.argv.includes('--imodel-id') && !this.argv.includes('-m') && context?.iModelId) { + this.argv.push('--imodel-id', context.iModelId); + } + } + + const parsed = await super.parse(options, argv); + + return parsed; + } + protected async runCommand(command: string, args: string[]) : Promise { const mergedArgs = [...args, '--silent']; return this.config.runCommand(command, mergedArgs); } + + protected async setContext(iTwinId: string, iModelId: string | undefined) { + const contextPath = this.config.cacheDir + '/context.json'; + + const context: UserContext = { + iModelId, + iTwinId + }; + + if (!fs.existsSync(this.config.cacheDir)) { + fs.mkdirSync(this.config.cacheDir, { recursive: true }); + } + + fs.writeFileSync(contextPath, JSON.stringify(context, null, 2), 'utf8'); + + return context; + } } \ No newline at end of file diff --git a/src/services/general-models/user-context.ts b/src/services/general-models/user-context.ts new file mode 100644 index 00000000..a4935aec --- /dev/null +++ b/src/services/general-models/user-context.ts @@ -0,0 +1,4 @@ +export type UserContext = { + iModelId?: string; + iTwinId?: string; +} \ No newline at end of file