Skip to content

feat(config), support saving config values in the local workspace/scope #9555

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 8 commits into from
Feb 13, 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
14 changes: 14 additions & 0 deletions .bitmap
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,20 @@
"mainFile": "index.ts",
"rootDir": "scopes/workspace/config-merger"
},
"config-store": {
"name": "config-store",
"scope": "",
"version": "",
"defaultScope": "teambit.harmony",
"mainFile": "index.ts",
"rootDir": "components/config-store",
"config": {
"teambit.harmony/aspect": {},
"teambit.envs/envs": {
"env": "teambit.harmony/aspect"
}
}
},
"constants": {
"name": "constants",
"scope": "teambit.legacy",
Expand Down
143 changes: 143 additions & 0 deletions components/config-store/config-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/* eslint max-classes-per-file: 0 */
import chalk from 'chalk';
import rightpad from 'pad-right';
import { BASE_DOCS_DOMAIN } from '@teambit/legacy.constants';
import { Command, CommandOptions } from '@teambit/cli';
import { ConfigStoreMain, StoreOrigin } from './config-store.main.runtime';

class ConfigSet implements Command {
name = 'set <key> <val>';
description = 'set a configuration. default to save it globally';
extendedDescription = `to set temporary configuration by env variable, prefix with "BIT_CONFIG", replace "." with "_" and change to upper case.
for example, "user.token" becomes "BIT_CONFIG_USER_TOKEN"`;
baseUrl = 'reference/config/bit-config/';
alias = '';
skipWorkspace = true;
options = [
['l', 'local', 'set the configuration in the current scope (saved in .bit/scope.json)'],
['t', 'local-track', 'set the configuration in the current workspace (saved in workspace.jsonc)'],
] as CommandOptions;

constructor(private configStore: ConfigStoreMain) {}

async report([key, value]: [string, string], { local, localTrack }: { local?: boolean, localTrack?: boolean }) {
const getOrigin = () => {
if (local) return 'scope';
if (localTrack) return 'workspace';
return 'global';
}
await this.configStore.setConfig(key, value, getOrigin());
return chalk.green('added configuration successfully');
}
}

class ConfigGet implements Command {
name = 'get <key>';
description = 'get a value from global configuration';
alias = '';
options = [] as CommandOptions;

constructor(private configStore: ConfigStoreMain) {}

async report([key]: [string]) {
const value = this.configStore.getConfig(key);
return value || '';
}
}

class ConfigList implements Command {
name = 'list';
description = 'list all configuration(s)';
alias = '';
options = [
['o', 'origin <origin>', 'list configuration specifically from the following: [scope, workspace, global]'],
['d', 'detailed', 'list all configuration(s) with the origin'],
['j', 'json', 'output as JSON'],
] as CommandOptions;

constructor(private configStore: ConfigStoreMain) {}

async report(_, { origin, detailed }: { origin?: StoreOrigin, detailed?: boolean }) {

const objToFormattedString = (conf: Record<string, string>) => {
return Object.entries(conf)
.map((tuple) => {
tuple[0] = rightpad(tuple[0], 45, ' ');
return tuple.join('');
})
.join('\n');
}

if (origin) {
const conf = this.configStore.stores[origin].list();
return objToFormattedString(conf);
}

if (detailed) {
const formatTitle = (str: string) => chalk.bold(str.toUpperCase());
const origins = Object.keys(this.configStore.stores).map((originName) => {
const conf = this.configStore.stores[originName].list();
return formatTitle(originName) + '\n' + objToFormattedString(conf);
}).join('\n\n');
const combined = this.configStore.listConfig();

const combinedFormatted = objToFormattedString(combined);
return `${origins}\n\n${formatTitle('All Combined')}\n${combinedFormatted}`;
}

const conf = this.configStore.listConfig();
return objToFormattedString(conf);
}

async json(_, { origin, detailed }: { origin?: StoreOrigin, detailed?: boolean }) {
if (origin) {
return this.configStore.stores[origin].list();
}
if (detailed) {
const allStores = Object.keys(this.configStore.stores).reduce((acc, current) => {
acc[current] = this.configStore.stores[current].list();
return acc;
}, {} as Record<string, Record<string, string>>);
allStores.combined = this.configStore.listConfig();
return allStores;
}
return this.configStore.listConfig();
}
}

class ConfigDel implements Command {
name = 'del <key>';
description = 'delete given key from global configuration';
alias = '';
options = [
['o', 'origin <origin>', 'default to delete whenever it found first. specify to delete specifically from the following: [scope, workspace, global]'],
] as CommandOptions;

constructor(private configStore: ConfigStoreMain) {}

async report([key]: [string], { origin }: { origin?: StoreOrigin }) {
await this.configStore.delConfig(key, origin);
return chalk.green('deleted successfully');
}
}

export class ConfigCmd implements Command {
name = 'config';
description = 'config management';
extendedDescription = `${BASE_DOCS_DOMAIN}reference/config/bit-config`;
group = 'general';
alias = '';
loadAspects = false;
commands: Command[] = [];
options = [] as CommandOptions;

constructor(private configStore: ConfigStoreMain) {
this.commands = [
new ConfigSet(configStore), new ConfigDel(configStore), new ConfigGet(configStore), new ConfigList(configStore)
];
}

async report() {
return new ConfigList(this.configStore).report(undefined, { });
}
}
122 changes: 122 additions & 0 deletions components/config-store/config-getter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import gitconfig from '@teambit/gitconfig';
import { isNil } from 'lodash';
import { GlobalConfig } from '@teambit/legacy.global-config';

export const ENV_VARIABLE_CONFIG_PREFIX = 'BIT_CONFIG_';

export interface Store {
list(): Record<string, string>;
set(key: string, value: string): void;
del(key: string): void;
write(): Promise<void>;
invalidateCache(): Promise<void>;
}

/**
* Singleton cache for the config object. so it can be used everywhere even by non-aspects components.
*/
export class ConfigGetter {
private _store: Record<string, string> | undefined;
private _globalConfig: GlobalConfig | undefined;
private gitStore: Record<string, string | undefined> = {};

get globalConfig() {
if (!this._globalConfig) {
this._globalConfig = GlobalConfig.loadSync();
}
return this._globalConfig;
}

get store() {
if (!this._store) {
this._store = this.globalConfig.toPlainObject();
}
return this._store;
}

/**
* in case a config-key exists in both, the new one (the given store) wins.
*/
addStore(store: Store) {
const currentStore = this.store;
this._store = { ...currentStore, ...store.list() };
}

getConfig(key: string): string | undefined {
if (!key) {
return undefined;
}

const envVarName = toEnvVariableName(key);
if (process.env[envVarName]) {
return process.env[envVarName];
}

const store = this.store;
const val = store[key];
if (!isNil(val)) {
return val;
}

if (key in this.gitStore) {
return this.gitStore[key];
}
try {
const gitVal = gitconfig.get.sync(key);
this.gitStore[key] = gitVal;
} catch {
// Ignore error from git config get
this.gitStore[key] = undefined;
}
return this.gitStore[key];
}
getConfigNumeric(key: string): number | undefined {
const fromConfig = this.getConfig(key);
if (isNil(fromConfig)) return undefined;
const num = Number(fromConfig);
if (Number.isNaN(num)) {
throw new Error(`config of "${key}" is invalid. Expected number, got "${fromConfig}"`);
}
return num;
}
getConfigBoolean(key: string): boolean | undefined {
const result = this.getConfig(key);
if (isNil(result)) return undefined;
if (typeof result === 'boolean') return result;
if (result === 'true') return true;
if (result === 'false') return false;
throw new Error(`the configuration "${key}" has an invalid value "${result}". it should be boolean`);
}
listConfig() {
const store = this.store;
return store;
}
invalidateCache() {
this._store = undefined;
}
getGlobalStore(): Store {
return {
list: () => this.globalConfig.toPlainObject(),
set: (key: string, value: string) => this.globalConfig.set(key, value),
del: (key: string) => this.globalConfig.delete(key),
write: async () => this.globalConfig.write(),
invalidateCache: async () => this._globalConfig = undefined,
};
}
}

export const configGetter = new ConfigGetter();

export function getConfig(key: string): string | undefined {
return configGetter.getConfig(key);
}
export function getNumberFromConfig(key: string): number | undefined {
return configGetter.getConfigNumeric(key);
}
export function listConfig(): Record<string, string> {
return configGetter.listConfig();
}

function toEnvVariableName(configName: string): string {
return `${ENV_VARIABLE_CONFIG_PREFIX}${configName.replace(/\./g, '_').toUpperCase()}`;
}
6 changes: 6 additions & 0 deletions components/config-store/config-store.aspect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Aspect } from '@teambit/harmony';

export const ConfigStoreAspect = Aspect.create({
id: 'teambit.harmony/config-store',
});

75 changes: 75 additions & 0 deletions components/config-store/config-store.main.runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import CLIAspect, { CLIMain, MainRuntime } from '@teambit/cli';
import { ConfigStoreAspect } from './config-store.aspect';
import { configGetter, Store } from './config-getter';
import { ConfigCmd } from './config-cmd';

export type StoreOrigin = 'scope' | 'workspace' | 'global';

export class ConfigStoreMain {
private _stores: { [origin: string]: Store } | undefined;
get stores (): { [origin: string]: Store } {
if (!this._stores) {
this._stores = {
global: configGetter.getGlobalStore()
};
}
return this._stores;
}
addStore(origin: StoreOrigin, store: Store) {
this.stores[origin] = store;
configGetter.addStore(store);
}
async invalidateCache() {
configGetter.invalidateCache();
for await (const origin of Object.keys(this.stores)) {
const store = this.stores[origin];
await store.invalidateCache();
configGetter.addStore(store);
};
}
async setConfig(key: string, value: string, origin: StoreOrigin = 'global') {
const store = this.stores[origin];
if (!store) throw new Error(`unable to set config, "${origin}" origin is missing`);
store.set(key, value);
await store.write();
await this.invalidateCache();
}
getConfig(key: string): string | undefined {
return configGetter.getConfig(key);
}
getConfigBoolean(key: string): boolean | undefined {
return configGetter.getConfigBoolean(key);
}
getConfigNumeric(key: string): number | undefined {
return configGetter.getConfigNumeric(key);
}
async delConfig(key: string, origin?: StoreOrigin) {
const getOrigin = () => {
if (origin) return origin;
return Object.keys(this.stores).find(originName => key in this.stores[originName].list());
}
const foundOrigin = getOrigin();
if (!foundOrigin) return; // if the key is not found in any store (or given store), nothing to do.
const store = this.stores[foundOrigin];
store.del(key);
await store.write();
await this.invalidateCache();
}
listConfig() {
return configGetter.listConfig();
}

static slots = [];
static dependencies = [CLIAspect];
static runtime = MainRuntime;
static async provider([cli]: [CLIMain]) {
const configStore = new ConfigStoreMain();
cli.register(new ConfigCmd(configStore));
return configStore;

}
}

ConfigStoreAspect.addRuntime(ConfigStoreMain);

export default ConfigStoreMain;
6 changes: 6 additions & 0 deletions components/config-store/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ConfigStoreAspect } from './config-store.aspect';

export type { ConfigStoreMain } from './config-store.main.runtime';
export default ConfigStoreAspect;
export { ConfigStoreAspect };
export { getConfig, getNumberFromConfig, listConfig, Store } from './config-getter';
4 changes: 2 additions & 2 deletions components/legacy/analytics/analytics-sender.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/* eslint-disable no-console */
import fetch from 'node-fetch';
import { getSync } from '@teambit/legacy.global-config';
import { CFG_ANALYTICS_DOMAIN_KEY, DEFAULT_ANALYTICS_DOMAIN } from '@teambit/legacy.constants';
import { getConfig } from '@teambit/config-store';

const ANALYTICS_DOMAIN = getSync(CFG_ANALYTICS_DOMAIN_KEY) || DEFAULT_ANALYTICS_DOMAIN;
const ANALYTICS_DOMAIN = getConfig(CFG_ANALYTICS_DOMAIN_KEY) || DEFAULT_ANALYTICS_DOMAIN;
/**
* to debug errors here, first, change the parent to have { silent: false }, in analytics.js `fork` call.
*/
Expand Down
Loading