Skip to content

Commit 94e8e99

Browse files
authored
feat(profiles): add project-level configuration support (#65)
* feat(profiles): add project-level configuration support Add .gitlab-mcp/ directory support for project-specific configuration: - ProjectPresetSchema: scope restrictions, features, denied_actions - ProjectProfileSchema: extends preset, tool selection - loadProjectConfig(): loads preset.yaml and profile.yaml - findProjectConfig(): walks up directory tree to find config - ScopeEnforcer: enforces project/namespace restrictions - ScopeViolationError: clear error for out-of-scope operations Scope supports: - Single project restriction - Namespace restriction (all projects in group) - Explicit projects list Includes 63 unit tests for project-loader and scope-enforcer. Closes #61 * feat(cli): add --no-project-config and --show-project-config flags - Add --no-project-config flag to skip loading project config - Add --show-project-config flag to display config and exit - Integrate findProjectConfig() into main entry point - Add tests for edge cases (file instead of directory, scope validation) - Improve test coverage to 100% for project-loader.ts * test(profiles): improve coverage for project-loader validation Add tests for: - validateProjectProfile with no overlap between additional_tools and denied_tools - getProjectConfigSummary with various profile field combinations Achieves 97%+ branch coverage. * refactor(profiles): address PR review comments - Remove redundant single project check in scope-enforcer.ts (project already added to allowedProjectsSet in constructor) - Remove misleading test that couldn't test claimed behavior - Remove redundant validation in project-loader.ts (combining project+projects already validated by Zod schema) - Export ScopeConfigSchema for test usage - Update test to use schema validation instead Achieves 100% coverage for scope-enforcer.ts * refactor(cli): extract CLI utilities to separate module for testability - Extract parseCliArgs and displayProjectConfig to src/cli-utils.ts - Add 26 unit tests covering CLI argument parsing and config display - main.ts now imports from cli-utils module Closes #61 * refactor(profiles): convert to async fs operations and fix test patterns - Use fs/promises instead of sync fs operations in project-loader.ts - Replace fail() with standard Jest assertion pattern in tests - Remove unused CliArgs import from cli-utils.test.ts - Update TODO comment with issue reference
1 parent 143831c commit 94e8e99

File tree

9 files changed

+2114
-31
lines changed

9 files changed

+2114
-31
lines changed

src/cli-utils.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* CLI utility functions for main entry point
3+
* Extracted for testability
4+
*/
5+
6+
import { logger } from "./logger";
7+
import { getProjectConfigSummary, ProjectConfig } from "./profiles";
8+
9+
/**
10+
* CLI arguments parsed from command line
11+
*/
12+
export interface CliArgs {
13+
profileName?: string;
14+
noProjectConfig: boolean;
15+
showProjectConfig: boolean;
16+
}
17+
18+
/**
19+
* Parse CLI arguments
20+
* @param argv - process.argv array (or mock for testing)
21+
* @returns Parsed CLI arguments
22+
*/
23+
export function parseCliArgs(argv: string[] = process.argv): CliArgs {
24+
const args = argv.slice(2);
25+
const result: CliArgs = {
26+
noProjectConfig: false,
27+
showProjectConfig: false,
28+
};
29+
30+
let profileCount = 0;
31+
32+
for (let i = 0; i < args.length; i++) {
33+
const arg = args[i];
34+
35+
if (arg === "--profile") {
36+
const value = args[i + 1];
37+
// Validate that value exists and is not another flag
38+
if (!value || value.startsWith("--")) {
39+
logger.error("--profile requires a profile name (e.g., --profile work)");
40+
throw new Error("--profile requires a profile name");
41+
}
42+
profileCount++;
43+
if (profileCount === 1) {
44+
result.profileName = value;
45+
}
46+
i++; // Skip value
47+
} else if (arg === "--no-project-config") {
48+
result.noProjectConfig = true;
49+
} else if (arg === "--show-project-config") {
50+
result.showProjectConfig = true;
51+
}
52+
}
53+
54+
if (profileCount > 1) {
55+
logger.warn({ count: profileCount }, "Multiple --profile flags detected, using first value");
56+
}
57+
58+
return result;
59+
}
60+
61+
/**
62+
* Display project configuration to console
63+
* @param config - Project configuration or null
64+
* @param output - Output function (default: console.log, can be mocked for testing)
65+
*/
66+
export function displayProjectConfig(
67+
config: ProjectConfig | null,
68+
output: (msg: string) => void = console.log
69+
): void {
70+
if (!config) {
71+
output("No project configuration found in current directory or parent directories.");
72+
output("\nTo create a project config, add .gitlab-mcp/ directory with:");
73+
output(" - preset.yaml (restrictions: scope, denied_actions, features)");
74+
output(" - profile.yaml (tool selection: extends, additional_tools)");
75+
return;
76+
}
77+
78+
const summary = getProjectConfigSummary(config);
79+
80+
output("Project Configuration");
81+
output("=====================");
82+
output(`Path: ${config.configPath}`);
83+
output("");
84+
85+
if (config.preset) {
86+
output("Preset (restrictions):");
87+
if (config.preset.description) {
88+
output(` Description: ${config.preset.description}`);
89+
}
90+
if (config.preset.scope) {
91+
if (config.preset.scope.project) {
92+
output(` Scope: project "${config.preset.scope.project}"`);
93+
} else if (config.preset.scope.namespace) {
94+
output(` Scope: namespace "${config.preset.scope.namespace}/*"`);
95+
} else if (config.preset.scope.projects) {
96+
output(` Scope: ${config.preset.scope.projects.length} projects`);
97+
for (const p of config.preset.scope.projects) {
98+
output(` - ${p}`);
99+
}
100+
}
101+
}
102+
if (config.preset.read_only) {
103+
output(" Read-only: yes");
104+
}
105+
if (config.preset.denied_actions?.length) {
106+
output(` Denied actions: ${config.preset.denied_actions.join(", ")}`);
107+
}
108+
if (config.preset.denied_tools?.length) {
109+
output(` Denied tools: ${config.preset.denied_tools.join(", ")}`);
110+
}
111+
if (config.preset.features) {
112+
const features = Object.entries(config.preset.features)
113+
.filter(([, v]) => v !== undefined)
114+
.map(([k, v]) => `${k}=${v}`)
115+
.join(", ");
116+
if (features) {
117+
output(` Features: ${features}`);
118+
}
119+
}
120+
output("");
121+
}
122+
123+
if (config.profile) {
124+
output("Profile (tool selection):");
125+
if (config.profile.description) {
126+
output(` Description: ${config.profile.description}`);
127+
}
128+
if (config.profile.extends) {
129+
output(` Extends: ${config.profile.extends}`);
130+
}
131+
if (config.profile.additional_tools?.length) {
132+
output(` Additional tools: ${config.profile.additional_tools.join(", ")}`);
133+
}
134+
if (config.profile.denied_tools?.length) {
135+
output(` Denied tools: ${config.profile.denied_tools.join(", ")}`);
136+
}
137+
if (config.profile.features) {
138+
const features = Object.entries(config.profile.features)
139+
.filter(([, v]) => v !== undefined)
140+
.map(([k, v]) => `${k}=${v}`)
141+
.join(", ");
142+
if (features) {
143+
output(` Features: ${features}`);
144+
}
145+
}
146+
output("");
147+
}
148+
149+
output("Summary:");
150+
if (summary.presetSummary) {
151+
output(` Preset: ${summary.presetSummary}`);
152+
}
153+
if (summary.profileSummary) {
154+
output(` Profile: ${summary.profileSummary}`);
155+
}
156+
}

src/main.ts

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,31 @@
22

33
import { startServer } from "./server";
44
import { logger } from "./logger";
5-
import { tryApplyProfileFromEnv } from "./profiles";
5+
import { tryApplyProfileFromEnv, findProjectConfig, getProjectConfigSummary } from "./profiles";
6+
import { parseCliArgs, displayProjectConfig } from "./cli-utils";
67

78
/**
8-
* Parse CLI arguments for --profile flag
9+
* Main entry point
910
*/
10-
function getProfileFromArgs(): string | undefined {
11-
const args = process.argv.slice(2);
12-
let profileName: string | undefined;
13-
let profileCount = 0;
11+
async function main(): Promise<void> {
12+
const cliArgs = parseCliArgs();
1413

15-
for (let i = 0; i < args.length; i++) {
16-
if (args[i] === "--profile") {
17-
const value = args[i + 1];
18-
// Validate that value exists and is not another flag
19-
if (!value || value.startsWith("--")) {
20-
logger.error("--profile requires a profile name (e.g., --profile work)");
21-
process.exit(1);
22-
}
23-
profileCount++;
24-
if (profileCount === 1) {
25-
profileName = value;
26-
}
14+
// Handle --show-project-config flag (display and exit)
15+
if (cliArgs.showProjectConfig) {
16+
try {
17+
const projectConfig = await findProjectConfig(process.cwd());
18+
displayProjectConfig(projectConfig);
19+
process.exit(0);
20+
} catch (error) {
21+
const message = error instanceof Error ? error.message : String(error);
22+
logger.error({ error: message }, "Failed to load project config");
23+
process.exit(1);
2724
}
2825
}
2926

30-
if (profileCount > 1) {
31-
logger.warn({ count: profileCount }, "Multiple --profile flags detected, using first value");
32-
}
33-
34-
return profileName;
35-
}
36-
37-
/**
38-
* Main entry point
39-
*/
40-
async function main(): Promise<void> {
4127
// Apply profile if specified (CLI arg > env var > default)
42-
const profileName = getProfileFromArgs();
4328
try {
44-
const result = await tryApplyProfileFromEnv(profileName);
29+
const result = await tryApplyProfileFromEnv(cliArgs.profileName);
4530
if (result) {
4631
// Handle both profile and preset results
4732
if ("profileName" in result) {
@@ -60,6 +45,32 @@ async function main(): Promise<void> {
6045
process.exit(1);
6146
}
6247

48+
// Load project config unless --no-project-config is specified
49+
if (!cliArgs.noProjectConfig) {
50+
try {
51+
const projectConfig = await findProjectConfig(process.cwd());
52+
if (projectConfig) {
53+
const summary = getProjectConfigSummary(projectConfig);
54+
logger.info(
55+
{
56+
path: projectConfig.configPath,
57+
preset: summary.presetSummary,
58+
profile: summary.profileSummary,
59+
},
60+
"Loaded project configuration"
61+
);
62+
63+
// Note: Project config is loaded and logged but not yet enforced.
64+
// Scope restrictions require integration with tool execution layer.
65+
// See: https://github.com/structured-world/gitlab-mcp/issues/61
66+
}
67+
} catch (error) {
68+
// Project config errors are warnings, not fatal
69+
const message = error instanceof Error ? error.message : String(error);
70+
logger.warn({ error: message }, "Failed to load project config, continuing without it");
71+
}
72+
}
73+
6374
// Start the server
6475
await startServer();
6576
}

src/profiles/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export {
2929
ProfileSchema,
3030
PresetSchema,
3131
ProfilesConfigSchema,
32+
// Project-level types
33+
ProjectPreset,
34+
ProjectProfile,
35+
ProjectConfig,
36+
ProjectPresetSchema,
37+
ProjectProfileSchema,
3238
} from "./types";
3339

3440
// Loader
@@ -44,3 +50,24 @@ export {
4450
ApplyProfileResult,
4551
ApplyPresetResult,
4652
} from "./applicator";
53+
54+
// Project-level config loader
55+
export {
56+
loadProjectConfig,
57+
findProjectConfig,
58+
validateProjectPreset,
59+
validateProjectProfile,
60+
getProjectConfigSummary,
61+
PROJECT_CONFIG_DIR,
62+
PROJECT_PRESET_FILE,
63+
PROJECT_PROFILE_FILE,
64+
} from "./project-loader";
65+
66+
// Scope enforcer
67+
export {
68+
ScopeEnforcer,
69+
ScopeViolationError,
70+
ScopeConfig,
71+
extractProjectsFromArgs,
72+
enforceArgsScope,
73+
} from "./scope-enforcer";

0 commit comments

Comments
 (0)