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
36 changes: 35 additions & 1 deletion src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,17 @@ export function deleteNestedValue(obj: Record<string, unknown>, path: string): b
* Coerce a string value to its appropriate type.
* - "true" / "false" -> boolean
* - Numeric strings -> number
* - JSON arrays/objects -> parsed containers
* - Everything else -> string
*
* @param value - The string value to coerce
* @param forceString - If true, always return the value as a string
* @returns The coerced value
*/
export function coerceValue(value: string, forceString: boolean = false): string | number | boolean {
export function coerceValue(
value: string,
forceString: boolean = false
): string | number | boolean | unknown[] | Record<string, unknown> {
if (forceString) {
return value;
}
Expand All @@ -171,9 +175,39 @@ export function coerceValue(value: string, forceString: boolean = false): string
return num;
}

const jsonContainer = parseJsonContainer(value);
if (jsonContainer !== undefined) {
return jsonContainer;
}

return value;
}

function parseJsonContainer(value: string): unknown[] | Record<string, unknown> | undefined {
const trimmed = value.trim();
const looksLikeContainer =
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
(trimmed.startsWith('{') && trimmed.endsWith('}'));

if (!looksLikeContainer) {
return undefined;
}

try {
const parsed: unknown = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed !== null && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
return undefined;
}

return undefined;
}

/**
* Format a value for YAML-like display.
*
Expand Down
27 changes: 27 additions & 0 deletions test/commands/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Command } from 'commander';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';

async function runConfigCommand(args: string[]): Promise<void> {
const { registerConfigCommand } = await import('../../src/commands/config.js');
const program = new Command();
registerConfigCommand(program);
await program.parseAsync(['node', 'openspec', 'config', ...args]);
}

describe('config command integration', () => {
// These tests use real file system operations with XDG_CONFIG_HOME override
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
// Create unique temp directory for each test
Expand All @@ -20,6 +29,7 @@ describe('config command integration', () => {

// Spy on console.error
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
Expand All @@ -31,6 +41,7 @@ describe('config command integration', () => {

// Restore spies
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();

// Reset module cache to pick up new XDG_CONFIG_HOME
vi.resetModules();
Expand Down Expand Up @@ -89,6 +100,22 @@ describe('config command integration', () => {
expect(config.featureFlags).toEqual({});
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON'));
});

it('should set workflows from JSON array syntax', async () => {
await runConfigCommand([
'set',
'workflows',
'["new","ff","apply","archive"]',
]);

const { getGlobalConfig } = await import('../../src/core/global-config.js');
const config = getGlobalConfig();

expect(config.workflows).toEqual(['new', 'ff', 'apply', 'archive']);
expect(consoleLogSpy).toHaveBeenCalledWith(
'Set workflows = new,ff,apply,archive'
);
});
});

describe('config command shell completion registry', () => {
Expand Down
28 changes: 28 additions & 0 deletions test/core/config-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,23 @@ describe('config-schema', () => {
expect(coerceValue('hello')).toBe('hello');
});

it('should parse JSON arrays', () => {
expect(coerceValue('["new","ff","apply","archive"]')).toEqual([
'new',
'ff',
'apply',
'archive',
]);
});

it('should parse JSON objects', () => {
expect(coerceValue('{"nested":"value"}')).toEqual({ nested: 'value' });
});

it('should keep malformed JSON containers as strings', () => {
expect(coerceValue('["new",')).toBe('["new",');
});

it('should keep strings that start with numbers but are not numbers', () => {
expect(coerceValue('123abc')).toBe('123abc');
});
Expand All @@ -167,6 +184,7 @@ describe('config-schema', () => {
expect(coerceValue('true', true)).toBe('true');
expect(coerceValue('42', true)).toBe('42');
expect(coerceValue('hello', true)).toBe('hello');
expect(coerceValue('["new"]', true)).toBe('["new"]');
});

it('should not coerce Infinity to number (not finite)', () => {
Expand Down Expand Up @@ -318,6 +336,16 @@ describe('config-schema', () => {
expect(result.success).toBe(true);
expect((config.featureFlags as Record<string, unknown>).experimental).toBe(false);
});

it('should accept setting workflows from JSON array syntax', () => {
const config: Record<string, unknown> = { featureFlags: {}, profile: 'custom' };
const value = coerceValue('["new","ff","apply","archive"]');
setNestedValue(config, 'workflows', value);

const result = validateConfig(config);
expect(result.success).toBe(true);
expect(config.workflows).toEqual(['new', 'ff', 'apply', 'archive']);
});
});

describe('GlobalConfigSchema', () => {
Expand Down
Loading