diff --git a/src/schemas/config.v1.schema.json b/src/schemas/config.v1.schema.json new file mode 100644 index 0000000..fcbbcf1 --- /dev/null +++ b/src/schemas/config.v1.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://mpak.dev/schemas/config.v1.json", + "title": "mpak CLI Configuration v1", + "description": "Configuration file for the mpak CLI stored at ~/.mpak/config.json", + "type": "object", + "required": ["version", "lastUpdated"], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "const": "1.0.0", + "description": "Configuration schema version" + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of last configuration update" + }, + "registryUrl": { + "type": "string", + "format": "uri", + "description": "Custom registry URL (overrides default https://api.mpak.dev)" + }, + "packages": { + "type": "object", + "description": "Per-package configuration values (user_config)", + "additionalProperties": { + "type": "object", + "description": "Configuration key-value pairs for a specific package", + "additionalProperties": { + "type": "string" + } + } + } + } +} diff --git a/src/utils/config-manager.test.ts b/src/utils/config-manager.test.ts index 08094b0..ff58976 100644 --- a/src/utils/config-manager.test.ts +++ b/src/utils/config-manager.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { ConfigManager } from './config-manager.js'; -import { existsSync, rmSync } from 'fs'; +import { ConfigManager, ConfigCorruptedError } from './config-manager.js'; +import { existsSync, rmSync, writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; @@ -147,4 +147,184 @@ describe('ConfigManager', () => { }); }); + describe('config validation', () => { + beforeEach(() => { + // Ensure config directory exists for writing test files + if (!existsSync(testConfigDir)) { + mkdirSync(testConfigDir, { recursive: true, mode: 0o700 }); + } + }); + + it('should throw ConfigCorruptedError for invalid JSON', () => { + writeFileSync(testConfigFile, 'not valid json {{{', { mode: 0o600 }); + + const manager = new ConfigManager(); + expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError); + expect(() => manager.loadConfig()).toThrow(/invalid JSON/); + }); + + it('should throw ConfigCorruptedError when version is missing', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ lastUpdated: '2024-01-01T00:00:00Z' }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError); + expect(() => manager.loadConfig()).toThrow(/version/); + }); + + it('should throw ConfigCorruptedError when lastUpdated is missing', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ version: '1.0.0' }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError); + expect(() => manager.loadConfig()).toThrow(/lastUpdated/); + }); + + it('should throw ConfigCorruptedError when registryUrl is not a string', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ + version: '1.0.0', + lastUpdated: '2024-01-01T00:00:00Z', + registryUrl: 12345, + }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError); + expect(() => manager.loadConfig()).toThrow(/registryUrl must be a string/); + }); + + it('should throw ConfigCorruptedError when packages is not an object', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ + version: '1.0.0', + lastUpdated: '2024-01-01T00:00:00Z', + packages: 'not an object', + }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError); + expect(() => manager.loadConfig()).toThrow(/packages must be an object/); + }); + + it('should throw ConfigCorruptedError when package config is not an object', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ + version: '1.0.0', + lastUpdated: '2024-01-01T00:00:00Z', + packages: { + '@scope/pkg': 'not an object', + }, + }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError); + expect(() => manager.loadConfig()).toThrow(/packages.@scope\/pkg must be an object/); + }); + + it('should throw ConfigCorruptedError when package config value is not a string', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ + version: '1.0.0', + lastUpdated: '2024-01-01T00:00:00Z', + packages: { + '@scope/pkg': { + api_key: 12345, + }, + }, + }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError); + expect(() => manager.loadConfig()).toThrow(/packages.@scope\/pkg.api_key must be a string/); + }); + + it('should throw ConfigCorruptedError for unknown fields', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ + version: '1.0.0', + lastUpdated: '2024-01-01T00:00:00Z', + unknownField: 'should not be here', + }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError); + expect(() => manager.loadConfig()).toThrow(/unknown field: unknownField/); + }); + + it('should include config path in error', () => { + writeFileSync(testConfigFile, 'invalid json', { mode: 0o600 }); + + const manager = new ConfigManager(); + try { + manager.loadConfig(); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigCorruptedError); + expect((err as ConfigCorruptedError).configPath).toBe(testConfigFile); + } + }); + + it('should load valid minimal config', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ + version: '1.0.0', + lastUpdated: '2024-01-01T00:00:00Z', + }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + const config = manager.loadConfig(); + expect(config.version).toBe('1.0.0'); + expect(config.lastUpdated).toBe('2024-01-01T00:00:00Z'); + }); + + it('should load valid full config', () => { + writeFileSync( + testConfigFile, + JSON.stringify({ + version: '1.0.0', + lastUpdated: '2024-01-01T00:00:00Z', + registryUrl: 'https://custom.registry.com', + packages: { + '@scope/pkg': { + api_key: 'secret', + other_key: 'value', + }, + }, + }), + { mode: 0o600 } + ); + + const manager = new ConfigManager(); + const config = manager.loadConfig(); + expect(config.version).toBe('1.0.0'); + expect(config.registryUrl).toBe('https://custom.registry.com'); + expect(config.packages?.['@scope/pkg']?.api_key).toBe('secret'); + }); + }); + }); diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 98d9a84..9458d6e 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -2,6 +2,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; +/** + * Current config schema version + */ +export const CONFIG_VERSION = '1.0.0'; + /** * Per-package user configuration (stores user_config values) */ @@ -10,7 +15,7 @@ export interface PackageConfig { } /** - * Configuration structure + * Configuration structure (v1.0.0) */ export interface MpakConfig { version: string; @@ -19,6 +24,102 @@ export interface MpakConfig { packages?: Record; } +/** + * Error thrown when config file is corrupted or invalid + */ +export class ConfigCorruptedError extends Error { + constructor( + message: string, + public readonly configPath: string, + public readonly cause?: Error + ) { + super(message); + this.name = 'ConfigCorruptedError'; + } +} + +/** + * Validates that a parsed object conforms to the MpakConfig schema + */ +function validateConfig(data: unknown, configPath: string): MpakConfig { + if (typeof data !== 'object' || data === null) { + throw new ConfigCorruptedError( + 'Config file must be a JSON object', + configPath + ); + } + + const obj = data as Record; + + // Required fields + if (typeof obj.version !== 'string') { + throw new ConfigCorruptedError( + 'Config missing required field: version (string)', + configPath + ); + } + + if (typeof obj.lastUpdated !== 'string') { + throw new ConfigCorruptedError( + 'Config missing required field: lastUpdated (string)', + configPath + ); + } + + // Optional fields with type validation + if (obj.registryUrl !== undefined && typeof obj.registryUrl !== 'string') { + throw new ConfigCorruptedError( + 'Config field registryUrl must be a string', + configPath + ); + } + + if (obj.packages !== undefined) { + if (typeof obj.packages !== 'object' || obj.packages === null) { + throw new ConfigCorruptedError( + 'Config field packages must be an object', + configPath + ); + } + + // Validate each package config + for (const [pkgName, pkgConfig] of Object.entries( + obj.packages as Record + )) { + if (typeof pkgConfig !== 'object' || pkgConfig === null) { + throw new ConfigCorruptedError( + `Config packages.${pkgName} must be an object`, + configPath + ); + } + + for (const [key, value] of Object.entries( + pkgConfig as Record + )) { + if (typeof value !== 'string') { + throw new ConfigCorruptedError( + `Config packages.${pkgName}.${key} must be a string`, + configPath + ); + } + } + } + } + + // Check for unknown fields (additionalProperties: false in schema) + const knownFields = new Set(['version', 'lastUpdated', 'registryUrl', 'packages']); + for (const key of Object.keys(obj)) { + if (!knownFields.has(key)) { + throw new ConfigCorruptedError( + `Config contains unknown field: ${key}`, + configPath + ); + } + } + + return data as MpakConfig; +} + /** * Configuration manager for CLI settings in ~/.mpak/config.json */ @@ -46,25 +147,38 @@ export class ConfigManager { if (!existsSync(this.configFile)) { this.config = { - version: '1.0.0', + version: CONFIG_VERSION, lastUpdated: new Date().toISOString(), }; this.saveConfig(); return this.config; } + let configJson: string; try { - const configJson = readFileSync(this.configFile, 'utf8'); - this.config = JSON.parse(configJson) as MpakConfig; - return this.config; - } catch { - this.config = { - version: '1.0.0', - lastUpdated: new Date().toISOString(), - }; - this.saveConfig(); - return this.config; + configJson = readFileSync(this.configFile, 'utf8'); + } catch (err) { + throw new ConfigCorruptedError( + `Failed to read config file: ${err instanceof Error ? err.message : String(err)}`, + this.configFile, + err instanceof Error ? err : undefined + ); } + + let parsed: unknown; + try { + parsed = JSON.parse(configJson); + } catch (err) { + throw new ConfigCorruptedError( + `Config file contains invalid JSON: ${err instanceof Error ? err.message : String(err)}`, + this.configFile, + err instanceof Error ? err : undefined + ); + } + + // Validate structure against schema + this.config = validateConfig(parsed, this.configFile); + return this.config; } private saveConfig(): void {