Skip to content
Closed
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
6 changes: 3 additions & 3 deletions agents-cli/src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ describe('Init Command', () => {
local: {
remote: {
api: 'http://localhost:3002',
manageUi: 'http://localhost:3001',
manageUi: 'http://localhost:3000',
},
credential: 'none',
environment: 'development',
Expand Down Expand Up @@ -310,7 +310,7 @@ describe('Init Command', () => {
expect(mockProfileManager.addProfile).toHaveBeenCalledWith('local', {
remote: {
api: 'http://localhost:3002',
manageUi: 'http://localhost:3001',
manageUi: 'http://localhost:3000',
},
credential: 'none',
environment: 'development',
Expand All @@ -334,7 +334,7 @@ describe('Init Command', () => {
local: {
remote: {
api: 'http://localhost:3002',
manageUi: 'http://localhost:3001',
manageUi: 'http://localhost:3000',
},
credential: 'none',
environment: 'development',
Expand Down
182 changes: 182 additions & 0 deletions agents-cli/src/__tests__/commands/profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import * as p from '@clack/prompts';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockProfileManager = vi.hoisted(() => ({
getProfile: vi.fn().mockReturnValue(undefined),
addProfile: vi.fn(),
setActiveProfile: vi.fn(),
checkCredentialExists: vi.fn().mockResolvedValue(false),
}));

vi.mock('@clack/prompts');

vi.mock('../../utils/profiles', async () => {
const actual = await vi.importActual('../../utils/profiles');
return {
...actual,
ProfileManager: vi.fn(() => mockProfileManager),
};
});

import { profileAddCommand } from '../../commands/profile';
import { LOCAL_REMOTE } from '../../utils/profiles';

describe('profileAddCommand', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
vi.clearAllMocks();
mockProfileManager.checkCredentialExists.mockResolvedValue(false);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('Local remote type', () => {
it('should create profile with LOCAL_REMOTE URLs without URL prompts', async () => {
vi.mocked(p.select).mockResolvedValueOnce('local');
vi.mocked(p.text).mockResolvedValueOnce('development'); // environment
vi.mocked(p.confirm).mockResolvedValueOnce(false); // switch profile

await profileAddCommand('test-local');

expect(mockProfileManager.addProfile).toHaveBeenCalledWith('test-local', {
remote: { api: LOCAL_REMOTE.api, manageUi: LOCAL_REMOTE.manageUi },
credential: 'none',
environment: 'development',
});
});

it('should not prompt for credential when Local is selected', async () => {
vi.mocked(p.select).mockResolvedValueOnce('local');
vi.mocked(p.text).mockResolvedValueOnce('development'); // environment
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('test-local');

// p.text called once (environment only), not for API URL, Manage UI URL, or credential
expect(p.text).toHaveBeenCalledTimes(1);
expect(p.text).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Environment name:' })
);
});

it('should skip credential keychain warning for local profiles', async () => {
vi.mocked(p.select).mockResolvedValueOnce('local');
vi.mocked(p.text).mockResolvedValueOnce('development');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('test-local');

expect(mockProfileManager.checkCredentialExists).not.toHaveBeenCalled();
});

it('should default environment to development for local', async () => {
vi.mocked(p.select).mockResolvedValueOnce('local');
vi.mocked(p.text).mockResolvedValueOnce('development');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('my-local');

expect(p.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Environment name:',
initialValue: 'development',
})
);
});
});

describe('Cloud remote type', () => {
it('should create profile with cloud remote string', async () => {
vi.mocked(p.select).mockResolvedValueOnce('cloud');
vi.mocked(p.text)
.mockResolvedValueOnce('production') // environment
.mockResolvedValueOnce('inkeep-my-cloud'); // credential
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('my-cloud');

expect(mockProfileManager.addProfile).toHaveBeenCalledWith('my-cloud', {
remote: 'cloud',
credential: 'inkeep-my-cloud',
environment: 'production',
});
});

it('should default environment to production for cloud', async () => {
vi.mocked(p.select).mockResolvedValueOnce('cloud');
vi.mocked(p.text).mockResolvedValueOnce('production').mockResolvedValueOnce('inkeep-test');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('test-cloud');

expect(p.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Environment name:',
initialValue: 'production',
})
);
});
});

describe('Custom remote type', () => {
it('should prompt for URLs with no initialValue', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 Consider: Add explicit assertion for no initialValue on Custom URL prompts

Issue: The test title says "should prompt for URLs with no initialValue" but doesn't explicitly assert this. The test relies on the mock call sequence implicitly.

Why: An explicit assertion would make the test intention clearer and catch regressions if someone accidentally adds initialValue to Custom prompts.

Fix: Add an assertion like:

expect(p.text).toHaveBeenCalledWith(
  expect.objectContaining({
    message: 'Agents API URL:',
    initialValue: undefined,
  })
);

Refs:

vi.mocked(p.select).mockResolvedValueOnce('custom');
vi.mocked(p.text)
.mockResolvedValueOnce('https://api.staging.example.com') // api URL
.mockResolvedValueOnce('https://manage.staging.example.com') // manage UI URL
.mockResolvedValueOnce('staging') // environment
.mockResolvedValueOnce('inkeep-staging'); // credential
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('staging');

expect(mockProfileManager.addProfile).toHaveBeenCalledWith('staging', {
remote: {
api: 'https://api.staging.example.com',
manageUi: 'https://manage.staging.example.com',
},
credential: 'inkeep-staging',
environment: 'staging',
});
});

it('should default environment to production for custom', async () => {
vi.mocked(p.select).mockResolvedValueOnce('custom');
vi.mocked(p.text)
.mockResolvedValueOnce('https://api.example.com')
.mockResolvedValueOnce('https://manage.example.com')
.mockResolvedValueOnce('production')
.mockResolvedValueOnce('inkeep-prod');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('prod');

expect(p.text).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Environment name:',
initialValue: 'production',
})
);
});

it('should check keychain for credential and warn if missing', async () => {
mockProfileManager.checkCredentialExists.mockResolvedValueOnce(false);
vi.mocked(p.select).mockResolvedValueOnce('custom');
vi.mocked(p.text)
.mockResolvedValueOnce('https://api.example.com')
.mockResolvedValueOnce('https://manage.example.com')
.mockResolvedValueOnce('production')
.mockResolvedValueOnce('inkeep-prod');
vi.mocked(p.confirm).mockResolvedValueOnce(false);

await profileAddCommand('prod');

expect(mockProfileManager.checkCredentialExists).toHaveBeenCalledWith('inkeep-prod');
});
});
});
9 changes: 5 additions & 4 deletions agents-cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import chalk from 'chalk';
import { checkKeychainAvailability, loadCredentials } from '../utils/credentials';
import {
DEFAULT_PROFILES_CONFIG,
LOCAL_REMOTE,
type Profile,
ProfileManager,
type ProfilesConfig,
Expand Down Expand Up @@ -387,7 +388,7 @@ async function localInitCommand(options?: InitOptions): Promise<void> {

if (options?.interactive === false) {
tenantId = 'default';
apiUrl = 'http://localhost:3002';
apiUrl = LOCAL_REMOTE.api;
} else {
const tenantIdInput = await p.text({
message: 'Enter your tenant ID:',
Expand Down Expand Up @@ -420,8 +421,8 @@ async function localInitCommand(options?: InitOptions): Promise<void> {

const apiUrlInput = await p.text({
message: 'Enter the Agents API URL:',
placeholder: 'http://localhost:3002',
initialValue: 'http://localhost:3002',
placeholder: LOCAL_REMOTE.api,
initialValue: LOCAL_REMOTE.api,
validate: validateUrl,
});

Expand Down Expand Up @@ -453,7 +454,7 @@ export default defineConfig({
const localProfile: Profile = {
remote: {
api: apiUrl,
manageUi: 'http://localhost:3001',
manageUi: LOCAL_REMOTE.manageUi,
},
credential: 'none',
environment: 'development',
Expand Down
71 changes: 42 additions & 29 deletions agents-cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as p from '@clack/prompts';
import chalk from 'chalk';
import { type Profile, ProfileError, ProfileManager } from '../utils/profiles';
import { LOCAL_REMOTE, type Profile, ProfileError, ProfileManager } from '../utils/profiles';

const profileManager = new ProfileManager();

Expand Down Expand Up @@ -75,7 +75,8 @@ export async function profileAddCommand(name?: string): Promise<void> {
message: 'Remote type:',
options: [
{ value: 'cloud', label: 'Inkeep Cloud', hint: 'Default cloud deployment' },
{ value: 'custom', label: 'Custom', hint: 'Local or self-hosted deployment' },
{ value: 'local', label: 'Local', hint: 'Local development (localhost)' },
{ value: 'custom', label: 'Custom', hint: 'Self-hosted or staging deployment' },
],
});

Expand All @@ -88,13 +89,14 @@ export async function profileAddCommand(name?: string): Promise<void> {

if (remoteType === 'cloud') {
remote = 'cloud';
} else if (remoteType === 'local') {
remote = { ...LOCAL_REMOTE };
} else {
// Get custom URLs
const api = await p.text({
message: 'Agents API URL:',
placeholder: 'http://localhost:3002',
initialValue: 'http://localhost:3002',
placeholder: 'https://your-agents-api.example.com',
validate: (value) => {
if (!value?.trim()) return 'URL is required';
try {
new URL(value);
return undefined;
Expand All @@ -111,9 +113,9 @@ export async function profileAddCommand(name?: string): Promise<void> {

const manageUi = await p.text({
message: 'Manage UI URL:',
placeholder: 'http://localhost:3000',
initialValue: 'http://localhost:3000',
placeholder: 'https://your-manage-ui.example.com',
validate: (value) => {
if (!value?.trim()) return 'URL is required';
try {
new URL(value);
return undefined;
Expand All @@ -134,11 +136,12 @@ export async function profileAddCommand(name?: string): Promise<void> {
};
}

// Get environment name
// Cloud and custom (self-hosted/staging) default to 'production'; only local dev defaults to 'development'
const envDefault = remoteType === 'local' ? 'development' : 'production';
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Minor: Custom profiles default to 'production' — verify this is intentional

Issue: The environment default is now 'production' for both Cloud and Custom profiles. Previously, non-cloud profiles defaulted to 'development'. This changes behavior for self-hosted users running profile add.

Why: Users setting up a staging environment (the hint says "Self-hosted or staging deployment") may be surprised their profile defaults to production. This is a subtle behavior change that could cause confusion if unintentional.

Fix: If intentional, consider either:

  1. Adding a comment explaining the rationale
  2. Updating the hint to "Self-hosted deployment" (removing "staging")
  3. Or leaving as-is if production is the safer default for authenticated deployments

Refs:

const environment = await p.text({
message: 'Environment name:',
placeholder: remoteType === 'cloud' ? 'production' : 'development',
initialValue: remoteType === 'cloud' ? 'production' : 'development',
placeholder: envDefault,
initialValue: envDefault,
validate: (value) => {
if (!value) return 'Environment is required';
return undefined;
Expand All @@ -151,20 +154,28 @@ export async function profileAddCommand(name?: string): Promise<void> {
}

// Generate credential reference name
const credentialDefault = `inkeep-${profileName}`;
const credential = await p.text({
message: 'Credential reference:',
placeholder: credentialDefault,
initialValue: credentialDefault,
validate: (value) => {
if (!value) return 'Credential reference is required';
return undefined;
},
});
let credential: string;

if (p.isCancel(credential)) {
p.cancel('Profile creation cancelled');
process.exit(0);
if (remoteType === 'local') {
credential = 'none';
} else {
const credentialDefault = `inkeep-${profileName}`;
const credentialInput = await p.text({
message: 'Credential reference:',
placeholder: credentialDefault,
initialValue: credentialDefault,
validate: (value) => {
if (!value) return 'Credential reference is required';
return undefined;
},
});

if (p.isCancel(credentialInput)) {
p.cancel('Profile creation cancelled');
process.exit(0);
}

credential = credentialInput;
}

// Create the profile
Expand All @@ -179,12 +190,14 @@ export async function profileAddCommand(name?: string): Promise<void> {
console.log();
console.log(chalk.green('✓'), `Profile '${chalk.cyan(profileName)}' created successfully.`);

// Check if credential exists and warn if not
const credentialExists = await profileManager.checkCredentialExists(credential);
if (!credentialExists) {
console.log();
console.log(chalk.yellow('⚠'), `Credential '${credential}' not found in keychain.`);
console.log(chalk.gray(' Run "inkeep login" to authenticate and store credentials.'));
// Check if credential exists and warn if not (skip for 'none')
if (credential !== 'none') {
const credentialExists = await profileManager.checkCredentialExists(credential);
if (!credentialExists) {
console.log();
console.log(chalk.yellow('⚠'), `Credential '${credential}' not found in keychain.`);
console.log(chalk.gray(' Run "inkeep login" to authenticate and store credentials.'));
}
}

// Ask if user wants to switch to this profile
Expand Down
5 changes: 3 additions & 2 deletions agents-cli/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { getLogger } from '@inkeep/agents-core';
import { loadCredentials } from './credentials';
import { LOCAL_REMOTE } from './profiles';
import { importWithTypeScriptSupport } from './tsx-loader';

const logger = getLogger('config');
Expand Down Expand Up @@ -340,8 +341,8 @@ export async function loadConfig(configPath?: string, tag?: string): Promise<Ink

// 1. Start with default config (lowest priority)
const config: InkeepConfig = {
agentsApiUrl: 'http://localhost:3002',
manageUiUrl: 'http://localhost:3000',
agentsApiUrl: LOCAL_REMOTE.api,
manageUiUrl: LOCAL_REMOTE.manageUi,
};

// 2. Override with file config (higher priority)
Expand Down
Loading