condu - Configuration as Code
condu is a configuration management tool for JavaScript/TypeScript projects that solves the "config hell" problem by providing a unified approach to manage all project configuration in code.
Why condu?
Modern JavaScript/TypeScript projects require numerous configuration files:
- tsconfig.json
- eslintrc/eslint.config.js
- .prettierrc
- .editorconfig
- package.json
- .gitignore
- And many more...
These configurations:
- Use different formats (JSON, YAML, JS)
- Are scattered across your project
- Are hard to keep in sync across multiple projects
- Often require changes to multiple files when adding a new tool
condu solves these problems by:
- Allowing you to define all configuration in TypeScript
- Providing a system to share and reuse configurations
- Making it easy to override only what you need
- Automating updates across your entire codebase
Getting Started
Installation
npm install condu --save-dev
yarn add condu --dev
pnpm add condu -D
Basic Usage
- Create a
.config/condu.ts file in your project root:
import { configure } from "condu";
import { typescript } from "@condu-feature/typescript";
import { eslint } from "@condu-feature/eslint";
import { prettier } from "@condu-feature/prettier";
export default configure({
features: [typescript(), eslint(), prettier()],
});
- Run condu to apply your configuration:
npx condu apply
This will generate all the necessary configuration files based on your .config/condu.ts file.
Features
Features are the building blocks of condu. Each feature manages configuration for a specific tool or aspect of your project.
Core Features
condu comes with many built-in features:
- typescript: Manages TypeScript configuration
- eslint: Configures ESLint
- prettier: Sets up Prettier formatting
- gitignore: Creates and manages .gitignore files
- vscode: Configures VS Code workspace settings
- editorconfig: Sets up EditorConfig
- pnpm/yarn/npm: Package manager configuration
- moon: Task runner integration
- vitest: Testing framework setup
- release-please: Release management
- And more...
Using Features
Each feature can be configured with options:
typescript({
preset: "esm-first",
tsconfig: {
compilerOptions: {
strict: true,
skipLibCheck: true,
},
},
});
Monorepo Support
condu excels at managing monorepo configurations. Define your workspace structure:
export default configure({
projects: [
{
parentPath: "packages/features",
nameConvention: "@myorg/feature-*",
},
{
parentPath: "packages/core",
nameConvention: "@myorg/*",
},
],
features: [
],
});
Creating Custom Features
You can create custom features to encapsulate your own configuration logic.
A feature's primary purpose is to define a recipe - a list of changes that should be made whenever condu apply is run. Think of the calls to condu recipe APIs similar to React component hooks.
Inline Features (Simplest Approach)
For one-off or simple modifications, you can define features inline directly in your config file:
import { configure } from "condu";
import { typescript } from "@condu-feature/typescript";
export default configure({
features: [
typescript(),
(condu) => {
condu.in({ kind: "package" }).modifyPublishedPackageJson((pkg) => ({
...pkg,
sideEffects: false,
}));
},
function addLicense(condu) {
condu.root.generateFile("LICENSE", {
content: `MIT License\n\nCopyright (c) ${new Date().getFullYear()} My Organization\n\n...`,
});
},
],
});
Inline features:
- Are perfect for quick, project-specific configurations
- Don't participate in the PeerContext system
- Are applied in the order they appear in the features array
Reusable Features with defineFeature
For creating proper reusable features, use the defineFeature function:
import { defineFeature } from "condu";
export const myFeature = (options = {}) =>
defineFeature("myFeature", {
defineRecipe(condu) {
condu.root.generateFile("my-config.json", {
content: {
enabled: options.enabled ?? true,
settings: options.settings ?? {},
},
stringify: JSON.stringify,
});
condu.root.ensureDependency("my-library");
condu.in({ kind: "package" }).modifyPackageJson((pkg) => ({
...pkg,
scripts: {
...pkg.scripts,
"my-script": "my-command",
},
}));
},
});
Using PeerContext for Feature Coordination
When you want features to influence each other, use the PeerContext system.
For example, the TypeScript feature could automatically enable TypeScript-specific ESLint rules as in the example below:
declare module "condu" {
interface PeerContext {
eslint: {
rules: Record<string, unknown>;
plugins: string[];
extends: string[];
};
}
}
export const eslint = (options = {}) =>
defineFeature("eslint", {
initialPeerContext: {
rules: {
"no-unused-vars": "error",
},
plugins: [],
extends: ["eslint:recommended"],
},
defineRecipe(condu, peerContext) {
condu.root.generateFile(".eslintrc.js", {
content: `module.exports = {
extends: ${JSON.stringify(peerContext.extends)},
plugins: ${JSON.stringify(peerContext.plugins)},
rules: ${JSON.stringify(peerContext.rules, null, 2)}
}`,
});
condu.root.ensureDependency("eslint");
for (const plugin of peerContext.plugins) {
condu.root.ensureDependency(`eslint-plugin-${plugin}`);
}
},
});
export const typescript = (options = {}) =>
defineFeature("typescript", {
initialPeerContext: {
config: {
strict: true,
},
},
modifyPeerContexts: (project, initialContext) => ({
eslint: (current) => ({
...current,
plugins: [...current.plugins, "typescript"],
extends: [...current.extends, "plugin:@typescript-eslint/recommended"],
rules: {
...current.rules,
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
},
}),
}),
defineRecipe(condu, peerContext) {
condu.root.generateFile("tsconfig.json", {
content: {
compilerOptions: peerContext.config,
},
stringify: (obj) => JSON.stringify(obj, null, 2),
});
condu.root.ensureDependency("typescript");
if (condu.project.hasFeature("eslint")) {
condu.root.ensureDependency("@typescript-eslint/parser");
condu.root.ensureDependency("@typescript-eslint/eslint-plugin");
}
},
});
With this setup:
- The ESLint feature defines its initial rules and plugin configuration
- The TypeScript feature enhances ESLint configuration with TypeScript-specific rules
- When both features are used together, you automatically get TypeScript-aware linting
Advanced Usage: defineGarnish for Post-Processing
For final adjustments after all features have run their main recipes, use defineGarnish:
import { defineFeature } from "condu";
export const packageScripts = () =>
defineFeature("packageScripts", {
defineRecipe(condu) {
condu.root.modifyPackageJson((pkg) => ({
...pkg,
scripts: {
...pkg.scripts,
start: "node index.js",
},
}));
},
defineGarnish(condu) {
const allTasks = condu.globalRegistry.tasks;
condu.root.modifyPackageJson((pkg) => {
const scripts = { ...pkg.scripts };
const buildTasks = allTasks.filter(
(task) => task.taskDefinition.type === "build",
);
if (buildTasks.length > 0) {
scripts["build:all"] = buildTasks
.map((t) => `npm run build:${t.taskDefinition.name}`)
.join(" && ");
for (const task of buildTasks) {
scripts[`build:${task.taskDefinition.name}`] =
task.taskDefinition.command;
}
}
const testTasks = allTasks.filter(
(task) => task.taskDefinition.type === "test",
);
if (testTasks.length > 0) {
scripts["test:all"] = testTasks
.map((t) => `npm run test:${t.taskDefinition.name}`)
.join(" && ");
}
return { ...pkg, scripts };
});
},
});
The defineGarnish function:
- Runs after all features have completed their main recipes
- Has access to
globalRegistry with information about all tasks, dependencies, and files
- Is perfect for generating aggregate configurations or scripts that depend on what other features defined
- Enables post-processing of files or configurations
API Reference
condu object
The main condu object available in feature recipes contains the following:
condu.project: Information about the project
condu.root: Recipe API for the root package
condu.in(criteria): Recipe API for the packages matching the criteria
Additionally, when used in defineGarnish:
condu.globalRegistry: Contains the summary of all the recipes, including:
- which files were modified
- what tasks were registered
Recipe API
Methods for declaring configuration changes:
generateFile
Creates files that are fully managed by condu.
generateFile<PathT extends string>(path: PathT, options: GenerateFileOptionsForPath<PathT>): ScopedRecipeApi
- Purpose: Generate new files that are completely managed by condu
- Features:
- Type-safe content generation
- Custom serialization support
- File attributes for special handling (e.g., gitignore)
Examples:
condu.root.generateFile("tsconfig.json", {
content: {
compilerOptions: {
strict: true,
target: "ES2020",
},
include: ["**/*.ts"],
},
stringify: (obj) => JSON.stringify(obj, null, 2),
});
condu.root.generateFile("pnpm-workspace.yaml", {
content: {
packages: ["packages/*"],
},
stringify: getYamlStringify(),
attributes: {
gitignore: false,
},
});
condu.root.generateFile(".gitignore", {
content: ["node_modules", "build", ".DS_Store", "*.log"].join("\n"),
});
modifyGeneratedFile
Modifies a file that was previously generated by condu.
modifyGeneratedFile<PathT extends string>(path: PathT, options: ModifyGeneratedFileOptions<ResolvedSerializedType<PathT>>): ScopedRecipeApi
- Purpose: Update or extend files already managed by condu
- Features:
- Access to current content
- Preserves format of the file
- Can be used to add or modify portions of existing files in a typesafe way
- Can be used without providing a parse/stringify, as it uses the one provided by the feature
Examples:
condu.root.modifyGeneratedFile("tsconfig.json", {
content: ({ content = {} }) => ({
...content,
compilerOptions: {
...content.compilerOptions,
declaration: true,
sourceMap: true,
},
}),
});
modifyUserEditableFile
Modifies files that should remain editable by users.
modifyUserEditableFile<PathT extends string, DeserializedT = ...>(path: PathT, options: ModifyUserEditableFileOptions<DeserializedT>): ScopedRecipeApi
- Purpose: Update portions of user-editable files while preserving other user changes
- Features:
- Custom parsing and stringification for different formats
- Can create the file if it doesn't exist with
ifNotExists: "create"
- Preserves content not explicitly modified by condu
Examples:
condu.root.modifyUserEditableFile(".vscode/settings.json", {
...getJsonParseAndStringify<MySettingsType>(),
ifNotExists: "create",
content: ({ content = {} }) => ({
...content,
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
}),
});
condu.root.modifyUserEditableFile(".npmrc", {
parse: (rawContent) => customParse(rawContent),
stringify: (data) => customStringify(data),
content: ({ content = {} }) => ({
...content,
"my-setting": "value",
}),
attributes: { gitignore: false },
});
ensureDependency
Ensures a dependency is installed in the package.
ensureDependency(name: string, dependency?: DependencyDefinitionInput): ScopedRecipeApi
- Purpose: Manage dependencies in package.json
- Features:
- Installation target customization (dev, peer, regular)
- Version specification
- Support for aliased packages
- Ability to mark as "built" for pnpm's
onlyBuiltDependencies
Examples:
condu.root.ensureDependency("typescript");
condu.root.ensureDependency("react", {
list: "dependencies",
version: "18.2.0",
installAsAlias: "react-aliased",
managed: "version",
});
condu.root.ensureDependency("react-dom", {
list: "peerDependencies",
rangePrefix: ">=",
built: true,
});
setDependencyResolutions
Sets dependency resolutions to override specific package versions.
setDependencyResolutions(resolutions: Record<string, string>): ScopedRecipeApi
- Purpose: Override dependency versions for all packages in the workspace
- Features:
- Works with different package managers (npm, yarn, pnpm)
- Adapts to the correct syntax for each package manager
Examples:
condu.root.setDependencyResolutions({
lodash: "4.17.21",
"webpack/tapable": "2.2.1",
"@types/react": "18.0.0",
});
modifyPackageJson
Modifies the package.json file with a custom transformer function.
modifyPackageJson(modifier: PackageJsonModifier): ScopedRecipeApi
- Purpose: Make changes to package.json
- Features:
- Full access to the package.json content
- Type-safe with package.json type definitions
- Can access the global registry state
Examples:
condu.root.modifyPackageJson((pkg) => ({
...pkg,
scripts: {
...pkg.scripts,
build: "tsc -p tsconfig.json",
test: "vitest run",
lint: "eslint .",
},
keywords: [...(pkg.keywords || []), "condu-managed"],
}));
condu.in({ kind: "package" }).modifyPackageJson((pkg) => ({
...pkg,
types: "./build/index.d.ts",
sideEffects: false,
}));
modifyPublishedPackageJson
Modifies the package.json that will be used during publishing.
modifyPublishedPackageJson(modifier: PackageJsonModifier): ScopedRecipeApi
- Purpose: Configure how the package.json appears when published to registries like npm
- Features:
- Only affects the published version, not the development version
- Perfect for export maps, types path adjustments, etc.
Examples:
condu.in({ kind: "package" }).modifyPublishedPackageJson((pkg) => ({
...pkg,
main: "./build/index.js",
module: "./build/index.js",
types: "./build/index.d.ts",
exports: {
".": {
import: "./build/index.js",
require: "./build/index.cjs",
types: "./build/index.d.ts",
},
"./package.json": "./package.json",
},
devDependencies: undefined,
}));
defineTask
Defines a task that can be run using a task runner.
defineTask(name: string, taskDefinition: Omit<Task, "name">): ScopedRecipeApi
- Purpose: Define tasks for build, test, etc. that can be run by task runners or package scripts
- Features:
- Task type categorization
- Dependencies between tasks
- Command definition
Examples:
condu.root.defineTask("build", {
type: "build",
command: "tsc -p tsconfig.json",
inputs: ["**/*.ts", "tsconfig.json"],
outputs: ["build/**"],
});
condu.root.defineTask("test", {
type: "test",
command: "vitest run",
deps: ["build"],
});
ignoreFile
Marks a file to be ignored by certain tools.
ignoreFile(path: string, options?: Omit<PartialGlobalFileAttributes, "inAllPackages">): ScopedRecipeApi
- Purpose: Add files to gitignore or configure other file attributes without generating content
- Features:
- Control file visibility in editors and VCS
Examples:
condu.root.ignoreFile("build/");
condu.root.ignoreFile("temp/debug.log", {
gitignore: true,
vscode: false,
});
PeerContext System
The PeerContext system enables features to share information and coordinate with each other:
- Declaring Context: Features declare what data is exposed and modifiable via TypeScript interface augmentation
declare module "condu" {
interface PeerContext {
myFeature: {
config: MyConfigType;
};
}
}
- Initializing Context: Features provide their initial context data
initialPeerContext: {
config: {
}
}
- Modifying Other Contexts: Features can modify other features' contexts
modifyPeerContexts: (project, initialContext) => ({
otherFeature: (current) => ({
...current,
someOption: true,
}),
});
- Using Context: Features get the final (merged) context data passed in to their recipes when they are applied
defineRecipe(condu, peerContext) {
}
This system enables powerful coordination between features without tight coupling.
To resolve any type-system issues when building a feature that might influence others, be sure to include the peer features as an optional peerDependency, with a broad version requirement (such as * or >=1.0.0).
CLI Reference
Condu provides a comprehensive CLI for managing your projects.
Core Commands
condu init [project-name]
Initializes a new condu project in the current directory or creates a new directory with the specified name.
condu init
condu init my-new-project
Options: None
The init command will:
- Create a
.config directory with a default condu.ts file
- Set up a package.json with the necessary dependencies
- Initialize a git repository if one doesn't exist
- Add a postinstall script that runs
condu apply
condu apply
Applies configuration from your .config/condu.ts file, generating or updating all configuration files.
condu apply
Options: None
This is the primary command you'll use to apply changes after modifying your condu configuration.
condu create <partial-path> [options]
Creates a new package in a monorepo according to your project conventions.
condu create features/my-feature
condu create features/my-feature --as @myorg/custom-name
Options:
--as <name>: Specify a custom package name
--description <text>: Add a description to the package.json
--private: Mark the package as private
condu tsc [options]
Builds TypeScript code and additionally creates CommonJS (.cjs) or ES Module (.mjs) versions of your code.
condu tsc --preset ts-to-cts
condu tsc --preset ts-to-mts
Options:
--preset ts-to-cts|ts-to-mts: Generate CommonJS or ES Module versions
- All standard TypeScript compiler options are supported
condu release [packages...] [options]
Prepares packages for release by generating distributable files and optionally publishing to npm.
condu release
condu release @myorg/package1 @myorg/package2
condu release --dry-run
Options:
--ci: Mark non-released packages as private (useful in CI environments)
--npm-tag <tag>: Specify the npm tag to use (default: latest)
--dry-run: Prepare packages without actually publishing
condu exec <command> [args...]
Executes a command in the context of a selected package.
condu exec npm run test
condu exec --pkg @myorg/my-package npm run test
Options:
--cwd <path>: Specify the working directory
--pkg <package>: Specify the target package
Helper Commands
condu help: Shows help information
condu version: Shows the current condu version
Best Practices
- Keep features focused: Each feature should manage one aspect of configuration
- Use peer contexts for cross-feature coordination, e.g. if you know that the project is TypeScript-based, you might want to enable TS-specific linters in your linter feature
- Use presets to combine common feature sets, making common boilerplates like
create-react-app obsolete
- Create custom features for organization-specific configuration patterns
- Commit the generated files to source control for transparency
Preset Example
Presets combine multiple features with sensible defaults:
export const monorepo =
(options = {}) =>
(pkg) => ({
projects: [
{
parentPath: "packages",
nameConvention: `@${pkg.name}/*`,
},
],
features: [
typescript(options.typescript),
eslint(options.eslint),
prettier(options.prettier),
pnpm(options.pnpm),
],
});
Use a preset in your project:
import { configure } from "condu";
import { monorepo } from "@condu-preset/monorepo";
export default configure(
monorepo({
typescript: {
preset: "commonjs-first",
},
}),
);
Conclusion
condu streamlines configuration management by:
- Centralizing all configuration in code
- Providing strong typing with TypeScript
- Enabling reuse across projects
- Minimizing boilerplate
- Making updates easier to apply
Say goodbye to config hell and focus on building your application!