Skip to content

W-18988586 feat: support paths for ConfigAggregator #1211

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 9, 2025
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
2 changes: 1 addition & 1 deletion messages/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The config value can only be set to true or false.

# invalidProjectWorkspace

This directory does not contain a valid Salesforce DX project.
%s does not contain a valid Salesforce DX project.

# schemaValidationError

Expand Down
79 changes: 48 additions & 31 deletions src/config/configAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { resolve } from 'node:path';
import { AsyncOptionalCreatable, merge, sortBy } from '@salesforce/kit';
import { AnyJson, Dictionary, isArray, isJsonMap, JsonMap, Optional } from '@salesforce/ts-types';
import { Messages } from '../messages';
Expand Down Expand Up @@ -77,7 +78,7 @@ export type ConfigInfo = {
*/
export class ConfigAggregator extends AsyncOptionalCreatable<ConfigAggregator.Options> {
protected static encrypted = true;
protected static instance: AsyncOptionalCreatable | undefined;
protected static instances = new Map<string, ConfigAggregator>();

private static readonly mutex = new Mutex();

Expand All @@ -98,15 +99,17 @@ export class ConfigAggregator extends AsyncOptionalCreatable<ConfigAggregator.Op
// Don't throw an project error with the aggregator, since it should resolve to global if
// there is no project.
try {
this.localConfig = new Config(Config.getDefaultOptions(false));
this.localConfig = new Config({
...Config.getDefaultOptions(false),
...(options?.projectPath ? { rootFolder: options.projectPath } : {}),
});
} catch (err) {
if ((err as Error).name !== 'InvalidProjectWorkspaceError') {
throw err;
}
}

this.globalConfig = new Config(Config.getDefaultOptions(true));

this.setAllowedProperties(Config.getAllowedProperties());
}

Expand All @@ -116,16 +119,27 @@ export class ConfigAggregator extends AsyncOptionalCreatable<ConfigAggregator.Op

// Use typing from AsyncOptionalCreatable to support extending ConfigAggregator.
// We really don't want ConfigAggregator extended but typescript doesn't support a final.
public static async create<T extends ConfigAggregator>(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this makes the types cleaner inside the method, but is kinda ugly
explanation: because this could be subclassed, the original let anything that extends AsyncOptionalCreatable use it.

we can have a tighter condition , where if we know the thing is really a ConfigAggregator (or its subclasses) the types can be more specific.

I'd also be happy to let create only work with ConfigAggregator but I don't understand heavy-OOP to know the implications. There are a few extends ConfigAggregator I saw in the wild on github so I wanted to be careful with the public API.

this: new (options?: ConfigAggregator.Options) => T,
options?: ConfigAggregator.Options
): Promise<T>;
public static async create<P extends ConfigAggregator.Options, T extends AsyncOptionalCreatable<P>>(
this: new (options?: P) => T,
options?: P
): Promise<T>;
public static async create<T extends ConfigAggregator>(
this: new (options?: ConfigAggregator.Options) => T,
options?: ConfigAggregator.Options
): Promise<T> {
return ConfigAggregator.mutex.lock(async () => {
let config = ConfigAggregator.instance as ConfigAggregator | undefined;
if (!config) {
config = ConfigAggregator.instance = new this(options) as unknown as ConfigAggregator;
await config.init();
const projectPath = options?.projectPath ? resolve(options.projectPath) : process.cwd();
if (!ConfigAggregator.instances.has(projectPath)) {
const agg = new this(options);
ConfigAggregator.instances.set(projectPath, agg);
await agg.init();
}
// we just either created the instance or got it from the cache
const config = ConfigAggregator.instances.get(projectPath)!;

if (ConfigAggregator.encrypted) {
await config.loadProperties();
Expand All @@ -136,18 +150,24 @@ export class ConfigAggregator extends AsyncOptionalCreatable<ConfigAggregator.Op
}

// console.log(ConfigAggregator.instance);
return ConfigAggregator.instance as T;
return config as T;
});
}

/**
* Clear the cache to force reading from disk.
* If no projectPath is provided, all instances will be cleared.
*
* *NOTE: Only call this method if you must and you know what you are doing.*
*/
public static async clearInstance(): Promise<void> {
public static async clearInstance(projectPath?: string): Promise<void> {
return ConfigAggregator.mutex.lock(() => {
ConfigAggregator.instance = undefined;
if (projectPath) {
const normalizedPath = resolve(projectPath);
ConfigAggregator.instances.delete(normalizedPath);
} else {
ConfigAggregator.instances.clear();
}
ConfigAggregator.encrypted = true; // Reset encryption flag as well
});
}
Expand All @@ -158,23 +178,23 @@ export class ConfigAggregator extends AsyncOptionalCreatable<ConfigAggregator.Op
*
* @param key The config key.
*/
public static getValue(key: string): ConfigInfo {
return this.getInstance().getInfo(key);
public static getValue(key: string, projectPath?: string): ConfigInfo {
return this.getInstance(projectPath ?? process.cwd()).getInfo(key);
}

/**
* Get the static ConfigAggregator instance. If one doesn't exist, one will be created with
* the **encrypted** config values. Encrypted config values need to be resolved
* asynchronously by calling {@link ConfigAggregator.reload}
*/
// Use typing from AsyncOptionalCreatable to support extending ConfigAggregator.
// We really don't want ConfigAggregator extended but typescript doesn't support a final.
private static getInstance<P, T extends AsyncOptionalCreatable<P>>(this: new () => T): T {
if (!ConfigAggregator.instance) {
ConfigAggregator.instance = new this();
(ConfigAggregator.instance as ConfigAggregator).loadPropertiesSync();
private static getInstance(projectPath = process.cwd()): ConfigAggregator {
const normalizedPath = resolve(projectPath);
if (!ConfigAggregator.instances.has(normalizedPath)) {
const instance = new ConfigAggregator({ projectPath: normalizedPath });
ConfigAggregator.instances.set(normalizedPath, instance);
instance.loadPropertiesSync();
}
return ConfigAggregator.instance as T;
return ConfigAggregator.instances.get(normalizedPath)!;
}

/**
Expand Down Expand Up @@ -434,18 +454,13 @@ export class ConfigAggregator extends AsyncOptionalCreatable<ConfigAggregator.Op

// Global config must be read first so it is on the left hand of the
// object assign and is overwritten by the local config.

const configs = [globalConfig];

// We might not be in a project workspace
if (localConfig) {
configs.push(localConfig);
}

configs.push(this.envVars);

const json: JsonMap = {};
return configs.filter(isJsonMap).reduce((acc: JsonMap, el: AnyJson) => merge(acc, el), json);
return [
globalConfig,
...(localConfig ? [localConfig] : []), // We might not be in a project workspace
this.envVars,
]
.filter(isJsonMap)
.reduce((acc: JsonMap, el: AnyJson) => merge(acc, el), {});
}
}

Expand All @@ -472,5 +487,7 @@ export namespace ConfigAggregator {

export type Options = {
customConfigMeta?: ConfigPropertyMeta[];
/** an absolute path to the project root */
projectPath?: string;
};
}
2 changes: 1 addition & 1 deletion src/testSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ export const restoreContext = (testContext: TestContext): void => {
SfProject.clearInstances();
// Allow each test to have their own config aggregator
// @ts-ignore clear for testing.
delete ConfigAggregator.instance;
ConfigAggregator.instances.clear();
};

/**
Expand Down
4 changes: 2 additions & 2 deletions src/util/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const SFDX_PROJECT_JSON = 'sfdx-project.json';
export async function resolveProjectPath(dir: string = process.cwd()): Promise<string> {
const projectPath = await traverse.forFile(dir, SFDX_PROJECT_JSON);
if (!projectPath) {
throw messages.createError('invalidProjectWorkspace');
throw messages.createError('invalidProjectWorkspace', [dir]);
}
return projectPath;
}
Expand All @@ -57,7 +57,7 @@ export async function resolveProjectPath(dir: string = process.cwd()): Promise<s
export function resolveProjectPathSync(dir: string = process.cwd()): string {
const projectPath = traverse.forFileSync(dir, SFDX_PROJECT_JSON);
if (!projectPath) {
throw messages.createError('invalidProjectWorkspace');
throw messages.createError('invalidProjectWorkspace', [dir]);
}
return projectPath;
}
Expand Down
163 changes: 163 additions & 0 deletions test/nut/configAggregator.nut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { promisify } from 'node:util';
import { exec } from 'node:child_process';
import * as fs from 'node:fs';
import path from 'node:path';
import { expect } from 'chai';
import { ConfigAggregator } from '../../src';

const execProm = promisify(exec);
const testDir = 'config-aggregator-nut-projects';
const testDir1 = path.join(testDir, 'test-project-1');
const testDir2 = path.join(testDir, 'test-project-2');
const testDir1Abs = path.resolve(testDir1);
const testDir2Abs = path.resolve(testDir2);

describe('configAggregator unique by paths', () => {
before(async () => {
// ensure a global config exists
// create 2 projects, each with a different target org
await fs.promises.mkdir(testDir, { recursive: true });
await Promise.all([
execProm(`sf project generate --name test-project-1 --output-dir ${testDir}`),
execProm(`sf project generate --name test-project-2 --output-dir ${testDir}`),
]);

// config a target-org in each project
await Promise.all([
execProm('sf config set org-max-query-limit=1', { cwd: testDir1 }),
execProm('sf config set org-max-query-limit=2', { cwd: testDir2 }),
]);
});

it('verify project 1 config', async () => {
const config = await ConfigAggregator.create({ projectPath: testDir1 });
expect(config.getPropertyValue('org-max-query-limit')).to.equal('1');
});

it('verify project 2 config', async () => {
const config = await ConfigAggregator.create({ projectPath: testDir2 });
expect(config.getPropertyValue('org-max-query-limit')).to.equal('2');
});

it('add a prop to only project 2, verify both projects are as expected after reloading', async () => {
await execProm('sf config set org-metadata-rest-deploy=true', { cwd: testDir2 });
const config = await ConfigAggregator.create({ projectPath: testDir2 });
expect(config.getPropertyValue('org-max-query-limit')).to.equal('2');

// undefined because singleton behavior prevents a reread
expect(config.getPropertyValue('org-metadata-rest-deploy')).to.equal(undefined);

// reload the config
await config.reload();
expect(config.getPropertyValue('org-max-query-limit')).to.equal('2');
expect(config.getPropertyValue('org-metadata-rest-deploy')).to.equal('true');
});

describe('clearInstance', () => {
it('clearInstance with specific path should only clear that project', async () => {
const [config1, config2] = await Promise.all([
ConfigAggregator.create({ projectPath: testDir1Abs }),
ConfigAggregator.create({ projectPath: testDir2Abs }),
]);

// Verify they have different values
expect(config1.getPropertyValue('org-max-query-limit')).to.equal('1');
expect(config2.getPropertyValue('org-max-query-limit')).to.equal('2');

// Clear only project 1
await ConfigAggregator.clearInstance(testDir1Abs);

// Project 1 should get a new instance, project 2 should be same
const newConfig1 = await ConfigAggregator.create({ projectPath: testDir1Abs });
const sameConfig2 = await ConfigAggregator.create({ projectPath: testDir2Abs });

// Verify behavior: new instance for project 1, same instance for project 2
expect(newConfig1).to.not.equal(config1);
expect(sameConfig2).to.equal(config2);

// Values should still be correct
expect(newConfig1.getPropertyValue('org-max-query-limit')).to.equal('1');
expect(sameConfig2.getPropertyValue('org-max-query-limit')).to.equal('2');
});

it('clearInstance without path should clear all instances', async () => {
const config1 = await ConfigAggregator.create({ projectPath: testDir1Abs });
const config2 = await ConfigAggregator.create({ projectPath: testDir2Abs });

// Clear all instances
await ConfigAggregator.clearInstance();

// Both projects should get new instances
const newConfig1 = await ConfigAggregator.create({ projectPath: testDir1Abs });
const newConfig2 = await ConfigAggregator.create({ projectPath: testDir2Abs });

// Verify both are new instances
expect(newConfig1).to.not.equal(config1);
expect(newConfig2).to.not.equal(config2);

// Values should still be correct
expect(newConfig1.getPropertyValue('org-max-query-limit')).to.equal('1');
expect(newConfig2.getPropertyValue('org-max-query-limit')).to.equal('2');
});
});

it('absolute and relative paths to same project should return same instance', async () => {
// Clear any existing instances to start fresh
await ConfigAggregator.clearInstance();

const config1 = await ConfigAggregator.create({ projectPath: testDir1Abs });
const config2 = await ConfigAggregator.create({ projectPath: testDir1 });

// Should be the same instance (after path normalization)
expect(config1).to.equal(config2);

// Both should have the same config value
expect(config1.getPropertyValue('org-max-query-limit')).to.equal('1');
expect(config2.getPropertyValue('org-max-query-limit')).to.equal('1');
});

describe('static getValue with project paths', () => {
it('getValue should work with different project paths', async () => {
const value1 = ConfigAggregator.getValue('org-max-query-limit', testDir1);
const value2 = ConfigAggregator.getValue('org-max-query-limit', testDir2);

expect(value1.value).to.equal('1');
expect(value2.value).to.equal('2');
expect(value1.location).to.equal('Local');
expect(value2.location).to.equal('Local');
});

it('getValue without project path should use current working directory', async () => {
// Change to project 1 directory
const originalCwd = process.cwd();
try {
process.chdir(testDir1Abs);
const value = ConfigAggregator.getValue('org-max-query-limit');
expect(value.value).to.equal('1');
expect(value.location).to.equal('Local');
} finally {
process.chdir(originalCwd);
}
});

it('getValue should normalize relative and absolute paths consistently', async () => {
const value1 = ConfigAggregator.getValue('org-max-query-limit', testDir1Abs);
const value2 = ConfigAggregator.getValue('org-max-query-limit', testDir1);

expect(value1.value).to.equal(value2.value);
expect(value1.location).to.equal(value2.location);
expect(value1.path).to.equal(value2.path);
});
});

after(async () => {
// remove the test projects
await fs.promises.rm(testDir, { recursive: true });
});
});
Loading