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
37 changes: 37 additions & 0 deletions src/schemas/config.v1.schema.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
184 changes: 182 additions & 2 deletions src/utils/config-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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');
});
});

});
Loading