Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/typescript_sdk_unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,11 @@ jobs:

- name: Run tests (npm)
run: npm run test

- name: Install 'configure' tool dependencies
working-directory: sdks/typescript/src/opik/configure
run: npm install

- name: Run 'configure' tool tests
working-directory: sdks/typescript/src/opik/configure
run: npm run test
8 changes: 6 additions & 2 deletions sdks/typescript/src/opik/configure/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@
"@clack/core": "^1.0.0",
"@clack/prompts": "1.0.0",
"@langchain/core": "^1.1.19",
"axios": "1.13.4",
"axios": "1.13.5",
"chalk": "^5.6.2",
"fast-glob": "^3.3.3",
"glob": "13.0.1",
"ini": "^5.0.0",
"inquirer": "^13.2.2",
"jsonc-parser": "^3.3.1",
"lodash": "^4.17.23",
Expand All @@ -67,6 +68,7 @@
"@types/yargs": "^17.0.35",
"dotenv": "^17.2.3",
"eslint": "^9.39.2",
"vitest": "^3.0.5",
"globals": "^17.3.0",
"prettier": "^3.8.1",
"tsup": "^8.3.6",
Expand All @@ -90,7 +92,9 @@
"fix:eslint": "eslint '**/*.{ts,tsx}' --fix",
"fmt:check": "prettier --check \"src/**/*.ts\"",
"try": "tsx bin.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"author": "Opik",
"license": "Apache-2.0",
Expand Down
11 changes: 11 additions & 0 deletions sdks/typescript/src/opik/configure/src/nodejs/node-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getOutroMessage } from '../lib/messages';
import {
addOrUpdateEnvironmentVariablesStep,
runPrettierStep,
saveToOpikConfigStep,
} from '../steps/index';
import { uploadEnvironmentVariablesStep } from '../steps/upload-environment-variables/index';
import { buildOpikApiUrl } from '../utils/urls';
Expand Down Expand Up @@ -153,6 +154,16 @@ export async function runNodejsWizard(options: WizardOptions): Promise<void> {
});
debug(`Environment variables added to ${relativeEnvFilePath}`);

debug('Saving configuration to ~/.opik.config');
await saveToOpikConfigStep({
projectName,
urlOverride: buildOpikApiUrl(host),
...(isLocalDeployment
? {}
: { apiKey: projectApiKey, workspace: workspaceName }),
});
debug('Configuration saved to ~/.opik.config');

analytics.capture('environment variables configured', {
envFilePath: relativeEnvFilePath,
addedEnvVariables,
Expand Down
1 change: 1 addition & 0 deletions sdks/typescript/src/opik/configure/src/steps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './add-editor-rules';
export * from './run-prettier';
export * from './add-or-update-environment-variables';
export * from './upload-environment-variables/index';
export * from './save-to-opik-config';
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import chalk from 'chalk';
import * as fs from 'fs';
import ini from 'ini';
import * as os from 'os';
import * as path from 'path';
import clack from '../utils/clack';

const OPIK_CONFIG_FILE_DEFAULT = path.join(os.homedir(), '.opik.config');

function expandPath(filePath: string): string {
return filePath.replace(/^~(?=$|\/|\\)/, os.homedir());
}

function resolveConfigFilePath(): string {
if (!process.env.OPIK_CONFIG_PATH) {
return OPIK_CONFIG_FILE_DEFAULT;
}

const customPath = expandPath(process.env.OPIK_CONFIG_PATH);
const parentDir = path.dirname(customPath);

if (!fs.existsSync(parentDir)) {
try {
fs.mkdirSync(parentDir, { recursive: true });
} catch (error) {
clack.log.warning(
`OPIK_CONFIG_PATH parent directory ${chalk.bold.cyan(parentDir)} could not be created: ${error instanceof Error ? error.message : String(error)}. Falling back to ${chalk.bold.cyan(OPIK_CONFIG_FILE_DEFAULT)}.`,
);
return OPIK_CONFIG_FILE_DEFAULT;
}
}

return customPath;
}

export interface SaveToOpikConfigOptions {
projectName: string;
urlOverride: string;
apiKey?: string;
workspace?: string;
}

/**
* Write Opik configuration values to ~/.opik.config (INI format).
* Merges with any existing content so unrelated sections are preserved.
*/
export async function saveToOpikConfigStep(
options: SaveToOpikConfigOptions,
): Promise<void> {
const { projectName, urlOverride, apiKey, workspace } = options;
const configFilePath = resolveConfigFilePath();

try {
let parsed: Record<string, unknown> = {};
if (fs.existsSync(configFilePath)) {
parsed = ini.parse(fs.readFileSync(configFilePath, 'utf8'));
}

const existing = (parsed['opik'] as Record<string, string> | undefined) ?? {};

parsed['opik'] = {
...existing,
url_override: urlOverride,
project_name: projectName,
...(apiKey ? { api_key: apiKey } : {}),
...(workspace ? { workspace } : {}),
};

await fs.promises.writeFile(configFilePath, ini.stringify(parsed), {
encoding: 'utf8',
flag: 'w',
});

clack.log.success(
`Saved Opik configuration to ${chalk.bold.cyan(configFilePath)}`,
);
} catch (error) {
clack.log.warning(
`Failed to save configuration to ${chalk.bold.cyan(configFilePath)}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import ini from 'ini';
import { saveToOpikConfigStep } from '../../src/steps';

// ESM requires module-level mocking for native node modules.
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
mkdirSync: vi.fn(),
promises: {
...actual.promises,
writeFile: vi.fn(),
},
};
});

// Silence clack output during tests.
vi.mock('../../src/utils/clack', () => ({
default: { log: { success: vi.fn(), warning: vi.fn() } },
}));

describe('saveToOpikConfigStep', () => {
let originalConfigPath: string | undefined;

beforeEach(() => {
originalConfigPath = process.env.OPIK_CONFIG_PATH;
delete process.env.OPIK_CONFIG_PATH;
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.readFileSync).mockReturnValue('');
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
});

afterEach(() => {
if (originalConfigPath !== undefined) {
process.env.OPIK_CONFIG_PATH = originalConfigPath;
} else {
delete process.env.OPIK_CONFIG_PATH;
}
vi.clearAllMocks();
});

function getWrittenParsed(): Record<string, Record<string, string>> {
const raw = vi.mocked(fs.promises.writeFile).mock.calls[0][1] as string;
return ini.parse(raw) as Record<string, Record<string, string>>;
}

it('creates the config file when it does not exist', async () => {
await saveToOpikConfigStep({
projectName: 'my-project',
urlOverride: 'http://localhost/api',
});

expect(fs.promises.writeFile).toHaveBeenCalledOnce();
const written = getWrittenParsed();
expect(written.opik.project_name).toBe('my-project');
expect(written.opik.url_override).toBe('http://localhost/api');
expect(written.opik.api_key).toBeUndefined();
expect(written.opik.workspace).toBeUndefined();
});

it('includes api_key and workspace for cloud deployments', async () => {
await saveToOpikConfigStep({
projectName: 'cloud-proj',
urlOverride: 'https://www.comet.com/opik/api',
apiKey: 'secret-key',
workspace: 'my-workspace',
});

expect(fs.promises.writeFile).toHaveBeenCalledOnce();
const written = getWrittenParsed();
expect(written.opik.api_key).toBe('secret-key');
expect(written.opik.workspace).toBe('my-workspace');
});

it('merges with an existing config file', async () => {
const existingContent = ini.stringify({
opik: { api_key: 'old-key', project_name: 'old-project' },
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(existingContent);

await saveToOpikConfigStep({
projectName: 'new-project',
urlOverride: 'https://www.comet.com/opik/api',
apiKey: 'new-key',
});

expect(fs.promises.writeFile).toHaveBeenCalledOnce();
const written = getWrittenParsed();
expect(written.opik.project_name).toBe('new-project');
expect(written.opik.api_key).toBe('new-key');
});

it('preserves other sections from an existing config file', async () => {
const existingContent = ini.stringify({
other: { foo: 'bar' },
opik: { project_name: 'old-project' },
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(existingContent);

await saveToOpikConfigStep({
projectName: 'new-project',
urlOverride: 'http://localhost/api',
});

const written = getWrittenParsed();
expect(written.other.foo).toBe('bar');
expect(written.opik.project_name).toBe('new-project');
});

it('writes to OPIK_CONFIG_PATH when the env var points to a path with an existing parent directory', async () => {
const customPath = '/custom/path/.opik.config';
process.env.OPIK_CONFIG_PATH = customPath;
vi.mocked(fs.existsSync).mockReturnValue(true); // parent dir exists

await saveToOpikConfigStep({
projectName: 'proj',
urlOverride: 'http://localhost/api',
});

expect(fs.promises.writeFile).toHaveBeenCalledOnce();
expect(vi.mocked(fs.promises.writeFile).mock.calls[0][0]).toBe(customPath);
});

it('creates parent directory and writes to OPIK_CONFIG_PATH when parent dir is missing', async () => {
const customPath = '/nonexistent/dir/.opik.config';
process.env.OPIK_CONFIG_PATH = customPath;
vi.mocked(fs.existsSync).mockReturnValue(false); // parent dir missing
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);

await saveToOpikConfigStep({
projectName: 'proj',
urlOverride: 'http://localhost/api',
});

expect(fs.mkdirSync).toHaveBeenCalledWith('/nonexistent/dir', { recursive: true });
expect(fs.promises.writeFile).toHaveBeenCalledOnce();
expect(vi.mocked(fs.promises.writeFile).mock.calls[0][0]).toBe(customPath);
});

it('falls back to default path when OPIK_CONFIG_PATH parent dir cannot be created', async () => {
process.env.OPIK_CONFIG_PATH = '/nonexistent/dir/.opik.config';
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.mkdirSync).mockImplementation(() => { throw new Error('EACCES'); });

await saveToOpikConfigStep({
projectName: 'proj',
urlOverride: 'http://localhost/api',
});

expect(fs.promises.writeFile).toHaveBeenCalledOnce();
const writtenPath = vi.mocked(fs.promises.writeFile).mock.calls[0][0] as string;
expect(writtenPath).not.toBe('/nonexistent/dir/.opik.config');
expect(writtenPath).toMatch(/\.opik\.config$/);
});

it('does not throw when writeFile fails — logs a warning instead', async () => {
vi.mocked(fs.promises.writeFile).mockRejectedValue(new Error('EACCES'));

await expect(
saveToOpikConfigStep({
projectName: 'proj',
urlOverride: 'http://localhost/api',
}),
).resolves.not.toThrow();
});
});
2 changes: 1 addition & 1 deletion sdks/typescript/src/opik/configure/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
"types": ["node"],
"typeRoots": ["./node_modules/@types", "./types"]
},
"include": ["src", "bin.ts", "index.ts", "types"],
"include": ["src", "tests", "bin.ts", "index.ts", "types"],
"exclude": ["node_modules", "dist"]
}
9 changes: 9 additions & 0 deletions sdks/typescript/src/opik/configure/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
},
});
Loading