Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions extensions/kortex-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "kortex-cli",
"displayName": "Kortex CLI",
"description": "Kortex CLI integration",
"version": "0.0.1-next",
"icon": "icon.png",
"publisher": "kortex",
"license": "Apache-2.0",
"engines": {
"kortex": "^0.0.1"
},
"main": "./dist/extension.js",
"contributes": {
"configuration": {}
},
"scripts": {
"build": "vite build && node scripts/build.js",
"test": "vitest run --coverage --passWithNoTests",
"test:watch": "vitest watch --coverage --passWithNoTests",
"watch": "vite build --watch"
},
"dependencies": {
"@kortex-app/api": "workspace:*",
"@octokit/rest": "^21.1.0",
"unzipper": "^0.11.6"
},
"devDependencies": {
"@types/unzipper": "^0.10.11",
"mkdirp": "^3.0.1",
"vite": "^7.0.6",
"vitest": "^4.0.10"
}
}
47 changes: 47 additions & 0 deletions extensions/kortex-cli/scripts/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/**********************************************************************
* Copyright (C) 2026 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

const AdmZip = require('adm-zip');
const path = require('path');
const packageJson = require('../package.json');
const fs = require('fs');
const { mkdirp } = require('mkdirp');

const destFile = path.resolve(__dirname, `../${packageJson.name}.cdix`);
const builtinDirectory = path.resolve(__dirname, '../builtin');
const unzippedDirectory = path.resolve(builtinDirectory, `${packageJson.name}.cdix`);
// remove the .cdix file before zipping
if (fs.existsSync(destFile)) {
fs.rmSync(destFile);
}
// remove the builtin folder before zipping
if (fs.existsSync(builtinDirectory)) {
fs.rmSync(builtinDirectory, { recursive: true, force: true });
}

const zip = new AdmZip();
zip.addLocalFolder(path.resolve(__dirname, '../'));
zip.writeZip(destFile);

// create unzipped built-in
(async () => {
await mkdirp(unzippedDirectory);
const unzip = new AdmZip(destFile);
unzip.extractAllTo(unzippedDirectory);
})();
43 changes: 43 additions & 0 deletions extensions/kortex-cli/src/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**********************************************************************
* Copyright (C) 2026 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import type { ExtensionContext } from '@kortex-app/api';
import { cli, env, process as processAPI, window } from '@kortex-app/api';
import type { OctokitOptions } from '@octokit/core';
import { Octokit } from '@octokit/rest';

import { KortexCLI } from './kortex-cli';
import { KortexCliDownloader } from './kortex-cli-downloader';

export async function activate(extensionContext: ExtensionContext): Promise<void> {
const octokitOptions: OctokitOptions = {};
if (process.env.GITHUB_TOKEN) {
octokitOptions.auth = process.env.GITHUB_TOKEN;
}
const octokit = new Octokit(octokitOptions);

const downloader = new KortexCliDownloader(extensionContext, octokit, env, window);
await downloader.init();
extensionContext.subscriptions.push(downloader);

const kortexCLI = new KortexCLI(cli, processAPI, downloader, env);
await kortexCLI.init();
extensionContext.subscriptions.push(kortexCLI);
}

export function deactivate(): void {}
235 changes: 235 additions & 0 deletions extensions/kortex-cli/src/kortex-cli-downloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/**********************************************************************
* Copyright (C) 2026 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { exec } from 'node:child_process';
import { existsSync } from 'node:fs';
import { chmod, mkdir, rm, unlink, writeFile } from 'node:fs/promises';
import { arch } from 'node:os';
import { join } from 'node:path';

import type {
CliTool,
Disposable,
env as EnvAPI,
ExtensionContext,
QuickPickItem,
window as WindowAPI,
} from '@kortex-app/api';
import type { components as OctokitComponents } from '@octokit/openapi-types';
import type { Octokit } from '@octokit/rest';
import { Open } from 'unzipper';

const GITHUB_ORG = 'kortex-hub';
const GITHUB_REPO = 'kortex-cli';

export interface ReleaseArtifactMetadata extends QuickPickItem {
tag: string;
id: number;
}

export class KortexCliDownloader implements Disposable {
#installDirectory: string;

constructor(
private readonly extensionContext: ExtensionContext,
private readonly octokit: Octokit,
private readonly envAPI: typeof EnvAPI,
private readonly windowAPI: typeof WindowAPI,
) {
this.#installDirectory = join(this.extensionContext.storagePath, 'kortex-cli-package');
}

async init(): Promise<void> {
if (!existsSync(this.#installDirectory)) {
await mkdir(this.#installDirectory, { recursive: true });
}
Comment on lines +57 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Recreate #installDirectory inside install().

init() is the only place that creates this directory, but uninstall() removes it. A reinstall in the same session will fail on macOS/Linux because tar -C expects the extraction target to already exist.

Proposed fix
  async install(release: ReleaseArtifactMetadata): Promise<string> {
    const destFile = await this.download(release);
+   await mkdir(this.#installDirectory, { recursive: true });
 
    if (destFile.endsWith('.zip')) {
      const directory = await Open.file(destFile);
      await directory.extract({ path: this.#installDirectory });

Also applies to: 75-82, 97-100

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@extensions/kortex-cli/src/kortex-cli-downloader.ts` around lines 57 - 60, The
install path fails if `#installDirectory` was removed by uninstall() because
init() is only called once; update the install() method in the
KortexCliDownloader (and similarly ensure any other extraction callers) to
recreate the target directory before running tar -C by calling
mkdir(this.#installDirectory, { recursive: true }) or equivalent; ensure
install() checks/creates this.#installDirectory immediately before extraction so
repeated uninstall/install in the same session succeeds.

}

dispose(): void {}

extractTarGz(filePath: string, outDir: string): Promise<void> {
return new Promise((resolve, reject) => {
// eslint-disable-next-line sonarjs/os-command
exec(`tar -xzf "${filePath}" -C "${outDir}"`, err => {
if (err) reject(err);
Comment on lines +65 to +69
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify shell-based command execution and data flow to command arguments.
rg -n -C2 'exec\(|execFile\(' extensions/kortex-cli/src/kortex-cli-downloader.ts
rg -n -C2 'asset\.name|destination|extractTarGz\(' extensions/kortex-cli/src/kortex-cli-downloader.ts

Repository: kortex-hub/kortex

Length of output: 1308


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check imports at top of file
head -50 extensions/kortex-cli/src/kortex-cli-downloader.ts | grep -n "import.*child_process"

# Get full extractTarGz method implementation
sed -n '65,75p' extensions/kortex-cli/src/kortex-cli-downloader.ts

# Check how filePath/outDir are used - verify no escaping
rg -n -B5 -A10 'extractTarGz\(destFile' extensions/kortex-cli/src/kortex-cli-downloader.ts

# Check asset.name and storageData sources
rg -n -B3 'const destination = join' extensions/kortex-cli/src/kortex-cli-downloader.ts

Repository: kortex-hub/kortex

Length of output: 1255


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify GitHub release asset names can contain special characters
# Check GitHub API documentation constraints on asset.name

# Also verify if there's any sanitization of asset.name before it's used
rg -n 'asset\.name' extensions/kortex-cli/src/kortex-cli-downloader.ts

# Check if there's any string escaping utility applied
rg -n 'escape|quote|shell' extensions/kortex-cli/src/kortex-cli-downloader.ts

Repository: kortex-hub/kortex

Length of output: 119


Use execFile() with argv array instead of exec() to prevent command injection via interpolated paths.

The exec() call on line 68 runs the command through a shell; filePath and outDir are interpolated as template literals. Since filePath derives from asset.name (which can contain arbitrary characters from GitHub releases), this allows shell metacharacters (backticks, $(...), semicolons, etc.) to be executed as commands rather than passed as literal arguments. Use execFile() with an arguments array to pass data directly to tar without shell parsing.

Suggested fix
-import { exec } from 'node:child_process';
+import { execFile } from 'node:child_process';
@@
   extractTarGz(filePath: string, outDir: string): Promise<void> {
     return new Promise((resolve, reject) => {
-      // eslint-disable-next-line sonarjs/os-command
-      exec(`tar -xzf "${filePath}" -C "${outDir}"`, err => {
+      execFile('tar', ['-xzf', filePath, '-C', outDir], err => {
         if (err) reject(err);
         else resolve();
       });
     });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
extractTarGz(filePath: string, outDir: string): Promise<void> {
return new Promise((resolve, reject) => {
// eslint-disable-next-line sonarjs/os-command
exec(`tar -xzf "${filePath}" -C "${outDir}"`, err => {
if (err) reject(err);
extractTarGz(filePath: string, outDir: string): Promise<void> {
return new Promise((resolve, reject) => {
execFile('tar', ['-xzf', filePath, '-C', outDir], err => {
if (err) reject(err);
else resolve();
});
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@extensions/kortex-cli/src/kortex-cli-downloader.ts` around lines 65 - 69, The
extractTarGz function currently uses exec with an interpolated shell command
causing command-injection risk; change it to call execFile (imported from
child_process) or the promisified variant and pass "tar" as the executable with
an argv array like ["-xzf", filePath, "-C", outDir] so paths are passed as
arguments (no shell parsing), and update the Promise callback to resolve/reject
based on execFile's error/result signature in extractTarGz.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exec → execFile suggestion was not applied because the goose extension uses the same
exec + // eslint-disable-next-line sonarjs/os-command pattern, and switching would require a
different lint suppression that doesn't match the codebase conventions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the goose extension for the exec + sonarjs/os-command pattern
rg -n -C3 'sonarjs/os-command\|exec\b' extensions/goose/ 2>/dev/null || \
rg -n -C3 'os-command' extensions/goose/ 2>/dev/null || \
rg -rn -C3 'sonarjs/os-command' extensions/ 2>/dev/null

Length of output: 589


@bmahabirbu, confirmed — extensions/goose/src/goose-downloader.ts lines 68–69 use the exact same exec + // eslint-disable-next-line sonarjs/os-command pattern. Keeping consistency with the established convention is reasonable.

That said, the underlying shell-injection risk remains for both extensions if filePath or outDir ever contain shell metacharacters. It may be worth tracking this as a follow-up to harden both the goose and kortex-cli extractors together at some point. Would you like me to open a GitHub issue to track that?


✏️ Learnings added
Learnt from: bmahabirbu
Repo: kortex-hub/kortex PR: 1213
File: extensions/kortex-cli/src/kortex-cli-downloader.ts:65-69
Timestamp: 2026-04-02T03:06:18.286Z
Learning: In the kortex-hub/kortex repository, the codebase convention for suppressing the `sonarjs/os-command` lint rule when using `exec()` for shell commands (e.g., tar extraction) is `// eslint-disable-next-line sonarjs/os-command` inline before the `exec()` call. This pattern is established in `extensions/goose/src/goose-downloader.ts` (line 68) and mirrored in `extensions/kortex-cli/src/kortex-cli-downloader.ts`. Switching to `execFile()` is intentionally avoided to maintain consistency with this pattern, even though `execFile()` would eliminate the shell-injection risk.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

else resolve();
});
});
}

async install(release: ReleaseArtifactMetadata): Promise<string> {
const destFile = await this.download(release);

if (destFile.endsWith('.zip')) {
const directory = await Open.file(destFile);
await directory.extract({ path: this.#installDirectory });
} else if (destFile.endsWith('.tar.gz') && (this.envAPI.isMac || this.envAPI.isLinux)) {
await this.extractTarGz(destFile, this.#installDirectory);
} else {
throw new Error(`Unsupported archive format: ${destFile}`);
}

await unlink(destFile);

const executablePath = this.getKortexExecutablePath();
if (!existsSync(executablePath)) {
throw new Error(`Kortex CLI executable was not found after extraction: ${executablePath}`);
}
if (!this.envAPI.isWindows) {
await chmod(executablePath, 0o755);
}

return executablePath;
}

async uninstall(): Promise<void> {
if (existsSync(this.#installDirectory)) {
await rm(this.#installDirectory, { recursive: true });
}
}

getKortexExecutablePath(): string {
const executable = this.envAPI.isWindows ? 'kortex.exe' : 'kortex';
return join(this.#installDirectory, executable);
}

async selectVersion(cliInfo?: CliTool): Promise<ReleaseArtifactMetadata> {
let releasesMetadata = await this.grabLatestReleasesMetadata();

if (releasesMetadata.length === 0) throw new Error('cannot grab kortex-cli releases');

if (cliInfo) {
releasesMetadata = releasesMetadata.filter(release => release.tag.slice(1) !== cliInfo.version);
}

const selectedRelease = await this.windowAPI.showQuickPick(releasesMetadata, {
placeHolder: 'Select Kortex CLI version to download',
});

if (!selectedRelease) {
throw new Error('No version selected');
}
Comment on lines +111 to +126
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle the empty release list after post-processing.

After filtering out the currently installed version, selectVersion() can end up with no candidates and still fall through to No version selected. getLatestVersionAsset() has the same gap and currently returns undefined despite its non-optional return type.

Proposed fix
  async selectVersion(cliInfo?: CliTool): Promise<ReleaseArtifactMetadata> {
    let releasesMetadata = await this.grabLatestReleasesMetadata();
 
    if (releasesMetadata.length === 0) throw new Error('cannot grab kortex-cli releases');
 
    if (cliInfo) {
      releasesMetadata = releasesMetadata.filter(release => release.tag.slice(1) !== cliInfo.version);
    }
+   if (releasesMetadata.length === 0) {
+     throw new Error('No other Kortex CLI versions are available');
+   }
 
    const selectedRelease = await this.windowAPI.showQuickPick(releasesMetadata, {
      placeHolder: 'Select Kortex CLI version to download',
    });
@@
  async getLatestVersionAsset(): Promise<ReleaseArtifactMetadata> {
    const latestReleases = await this.grabLatestReleasesMetadata();
+   if (latestReleases.length === 0) {
+     throw new Error('cannot grab kortex-cli releases');
+   }
    return latestReleases[0];
  }

Also applies to: 144-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@extensions/kortex-cli/src/kortex-cli-downloader.ts` around lines 108 - 123,
selectVersion may filter out all releases (releasesMetadata) and still call
showQuickPick, and getLatestVersionAsset can return undefined despite its
non-optional signature; after you filter releasesMetadata (in selectVersion)
check for releasesMetadata.length === 0 and throw a clear Error like "no other
Kortex CLI versions available" before calling windowAPI.showQuickPick, and
similarly update getLatestVersionAsset to validate its candidate list (or the
asset result) and throw a descriptive Error instead of returning undefined so
both methods always either return the expected non-optional type or raise a
clear exception (refer to selectVersion, releasesMetadata,
windowAPI.showQuickPick and getLatestVersionAsset to locate the code to change).

return selectedRelease;
}

async grabLatestReleasesMetadata(): Promise<ReleaseArtifactMetadata[]> {
const lastReleases = await this.octokit.repos.listReleases({
owner: GITHUB_ORG,
repo: GITHUB_REPO,
per_page: 10,
});

return lastReleases.data
.filter(release => !release.prerelease)
.map(release => ({
label: release.name ?? release.tag_name,
tag: release.tag_name,
id: release.id,
}))
.slice(0, 5);
}

async getLatestVersionAsset(): Promise<ReleaseArtifactMetadata> {
const latestReleases = await this.grabLatestReleasesMetadata();
return latestReleases[0];
}

async getReleaseAssetId(releaseId: number): Promise<OctokitComponents['schemas']['release-asset']> {
const architecture = arch();
let assetName: string;

if (this.envAPI.isWindows) {
switch (architecture) {
case 'x64':
assetName = 'windows_amd64.zip';
break;
case 'arm64':
assetName = 'windows_arm64.zip';
break;
default:
throw new Error(`Unsupported architecture for Windows: ${architecture}`);
}
} else if (this.envAPI.isMac) {
switch (architecture) {
case 'arm64':
assetName = 'darwin_arm64.tar.gz';
break;
case 'x64':
assetName = 'darwin_amd64.tar.gz';
break;
default:
throw new Error(`Unsupported architecture for macOS: ${architecture}`);
}
} else if (this.envAPI.isLinux) {
switch (architecture) {
case 'arm64':
assetName = 'linux_arm64.tar.gz';
break;
case 'x64':
assetName = 'linux_amd64.tar.gz';
break;
default:
throw new Error(`Unsupported architecture for Linux: ${architecture}`);
}
} else {
throw new Error('Unsupported platform');
}

const listOfAssets = await this.octokit.repos.listReleaseAssets({
owner: GITHUB_ORG,
repo: GITHUB_REPO,
release_id: releaseId,
per_page: 60,
});

const asset = listOfAssets.data.find(a => a.name.endsWith(assetName));
if (!asset) {
throw new Error(
`No asset found for ${architecture} on ${this.envAPI.isWindows ? 'Windows' : this.envAPI.isMac ? 'macOS' : 'Linux'}`,
);
}

return asset;
}

async download(release: ReleaseArtifactMetadata): Promise<string> {
const asset = await this.getReleaseAssetId(release.id);

const storageData = this.extensionContext.storagePath;
if (!existsSync(storageData)) {
await mkdir(storageData, { recursive: true });
}

const destination = join(storageData, asset.name);
await this.downloadReleaseAsset(asset.id, destination);
return destination;
}

protected async downloadReleaseAsset(assetId: number, destination: string): Promise<void> {
const asset = await this.octokit.repos.getReleaseAsset({
owner: GITHUB_ORG,
repo: GITHUB_REPO,
asset_id: assetId,
headers: {
accept: 'application/octet-stream',
},
});

await writeFile(destination, Buffer.from(asset.data as unknown as ArrayBuffer));
}
}
Loading
Loading