Skip to main content

Migrating from Make to Xec

Overview​

This guide helps you migrate from GNU Make and Makefiles to Xec's modern task automation system. While Make has been the standard for decades, Xec provides better cross-platform support, TypeScript integration, parallel execution control, and modern developer experience.

Why Migrate from Make?​

Make Limitations​

# Traditional Makefile
CC = gcc
CFLAGS = -Wall -O2

.PHONY: all clean test deploy

all: build

build: src/*.c
$(CC) $(CFLAGS) -o app src/*.c

test: build
./test/run-tests.sh

deploy: test
rsync -avz app user@server:/opt/
ssh user@server 'systemctl restart app'

clean:
rm -f app *.o

Problems:

  • Platform-specific (Unix-centric)
  • Arcane syntax with tabs vs spaces
  • Limited debugging capabilities
  • No built-in TypeScript/JavaScript support
  • Complex dependency management
  • Poor Windows support

Xec Advantages​

# .xec/config.yaml
tasks:
build:
description: Build application
command: gcc -Wall -O2 -o app src/*.c
outputs: [app]

test:
needs: [build]
command: ./test/run-tests.sh

deploy:
needs: [test]
targets: production
steps:
- name: Copy binary
command: xec copy app /opt/app
- name: Restart service
command: systemctl restart app

Benefits:

  • Cross-platform by design
  • Modern YAML/TypeScript syntax
  • Built-in parallel execution
  • SSH/Docker/K8s integration
  • Better error handling and logging
  • TypeScript for complex logic

Core Concepts Mapping​

Make β†’ Xec Terminology​

Make ConceptXec EquivalentDescription
TargetTaskUnit of work to execute
Prerequisitesneeds/dependsTask dependencies
Recipecommand/stepsCommands to execute
Variablesparams/envConfiguration values
Pattern Rulespatterns/globFile matching patterns
.PHONY(default)All tasks are phony by default
VPATHcwdWorking directory control
FunctionsTypeScriptFull programming language

Common Patterns Migration​

1. Basic Targets​

Makefile:

.PHONY: hello clean

hello:
echo "Hello, World!"

clean:
rm -rf build/
rm -f *.o *.tmp

Xec Tasks:

tasks:
hello:
command: echo "Hello, World!"

clean:
steps:
- command: rm -rf build/
- command: rm -f *.o *.tmp

Xec Script (Better):

// scripts/clean.ts
import { rm } from 'fs/promises';
import { glob } from '@xec-sh/core';

// Cross-platform clean
await rm('build', { recursive: true, force: true });

const tempFiles = await glob('**/*.{o,tmp}');
for (const file of tempFiles) {
await rm(file);
}

console.log(`βœ… Cleaned ${tempFiles.length} temporary files`);

2. Dependencies​

Makefile:

all: build test package

build: compile link

compile:
gcc -c src/*.c

link: compile
gcc *.o -o app

test: build
./run-tests

package: test
tar -czf app.tar.gz app

Xec Configuration:

tasks:
all:
needs: [build, test, package]

build:
needs: [compile, link]

compile:
command: gcc -c src/*.c

link:
needs: [compile]
command: gcc *.o -o app

test:
needs: [build]
command: ./run-tests

package:
needs: [test]
command: tar -czf app.tar.gz app

3. Variables and Substitution​

Makefile:

CC = gcc
CFLAGS = -Wall -O2
SRC_DIR = src
BUILD_DIR = build
SOURCES = $(wildcard $(SRC_DIR)/*.c)
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(BUILD_DIR)/%.o)

$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c
$(CC) $(CFLAGS) -c $< -o $@

app: $(OBJECTS)
$(CC) $(OBJECTS) -o $@

Xec with TypeScript:

// scripts/build.ts
import { $, glob } from '@xec-sh/core';
import path from 'path';

const CC = process.env.CC || 'gcc';
const CFLAGS = process.env.CFLAGS || '-Wall -O2';
const SRC_DIR = 'src';
const BUILD_DIR = 'build';

// Get all C source files
const sources = await glob(`${SRC_DIR}/*.c`);

// Compile each source file
for (const source of sources) {
const object = source
.replace(SRC_DIR, BUILD_DIR)
.replace('.c', '.o');

console.log(`Compiling ${source}...`);
await $`${CC} ${CFLAGS} -c ${source} -o ${object}`;
}

// Link all object files
const objects = await glob(`${BUILD_DIR}/*.o`);
await $`${CC} ${objects.join(' ')} -o app`;

console.log('βœ… Build complete!');

4. Pattern Rules​

Makefile:

%.pdf: %.md
pandoc $< -o $@

%.min.js: %.js
uglify $< -o $@

%.gz: %
gzip -c $< > $@

Xec Script:

// scripts/process-files.ts
import { $, glob } from '@xec-sh/core';

// Process markdown to PDF
const markdownFiles = await glob('**/*.md');
await Promise.all(
markdownFiles.map(file =>
$`pandoc ${file} -o ${file.replace('.md', '.pdf')}`
)
);

// Minify JavaScript
const jsFiles = await glob('src/**/*.js');
await Promise.all(
jsFiles.map(file =>
$`uglify ${file} -o ${file.replace('.js', '.min.js')}`
)
);

// Compress files
const filesToCompress = await glob('dist/*', { nodir: true });
await Promise.all(
filesToCompress.map(file =>
$`gzip -c ${file} > ${file}.gz`
)
);

5. Conditional Execution​

Makefile:

ifdef DEBUG
CFLAGS += -g -O0
else
CFLAGS += -O2
endif

ifeq ($(OS),Windows_NT)
RM = del /Q
else
RM = rm -f
endif

debug:
$(MAKE) DEBUG=1

Xec Configuration:

tasks:
build:
params:
- name: debug
type: boolean
default: false
env:
CFLAGS: ${params.debug ? '-g -O0' : '-O2'}
command: gcc ${env.CFLAGS} -o app src/*.c

Xec Script:

// scripts/build.ts
import { $ } from '@xec-sh/core';
import { platform } from 'os';

const isDebug = process.argv.includes('--debug');
const isWindows = platform() === 'win32';

// Platform-specific commands
const RM = isWindows ? 'del /Q' : 'rm -f';
const CFLAGS = isDebug ? '-g -O0' : '-O2';

// Clean
await $`${RM} *.o`;

// Build
await $`gcc ${CFLAGS} -o app src/*.c`;

if (isDebug) {
console.log('πŸ› Built with debug symbols');
}

6. Parallel Execution​

Makefile (Limited):

.PHONY: test
test: test-unit test-integration test-e2e

test-unit:
npm run test:unit

test-integration:
npm run test:integration

test-e2e:
npm run test:e2e

# Run with: make -j3 test

Xec (Native Parallel):

tasks:
test:
parallel: true
steps:
- name: Unit Tests
command: npm run test:unit
- name: Integration Tests
command: npm run test:integration
- name: E2E Tests
command: npm run test:e2e

Xec Script:

// scripts/test.ts
import { $ } from '@xec-sh/core';

// Run all test suites in parallel
const results = await Promise.allSettled([
$`npm run test:unit`,
$`npm run test:integration`,
$`npm run test:e2e`
]);

// Check results
const failed = results.filter(r => r.status === 'rejected');
if (failed.length > 0) {
console.error(`❌ ${failed.length} test suites failed`);
process.exit(1);
}

console.log('βœ… All tests passed!');

Complex Makefile Migration​

Real-World C++ Project​

Original Makefile:

CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
LDFLAGS = -lpthread -lssl -lcrypto
SRC_DIR = src
TEST_DIR = test
BUILD_DIR = build
TARGET = myapp

SOURCES = $(wildcard $(SRC_DIR)/*.cpp)
OBJECTS = $(SOURCES:$(SRC_DIR)/%.cpp=$(BUILD_DIR)/%.o)
TEST_SOURCES = $(wildcard $(TEST_DIR)/*.cpp)
TEST_OBJECTS = $(TEST_SOURCES:$(TEST_DIR)/%.cpp=$(BUILD_DIR)/test_%.o)

.PHONY: all clean test install docker

all: $(TARGET)

$(BUILD_DIR):
mkdir -p $(BUILD_DIR)

$(BUILD_DIR)/%.o: $(SRC_DIR)/%.cpp | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@

$(BUILD_DIR)/test_%.o: $(TEST_DIR)/%.cpp | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@

$(TARGET): $(OBJECTS)
$(CXX) $(OBJECTS) $(LDFLAGS) -o $@

test: $(TARGET) $(TEST_OBJECTS)
$(CXX) $(TEST_OBJECTS) $(LDFLAGS) -o $(BUILD_DIR)/test_runner
./$(BUILD_DIR)/test_runner

install: $(TARGET)
install -m 755 $(TARGET) /usr/local/bin/

docker:
docker build -t myapp:latest .
docker push myregistry/myapp:latest

clean:
rm -rf $(BUILD_DIR) $(TARGET)

watch:
while true; do \
make -q || make; \
inotifywait -qre close_write $(SRC_DIR); \
done

Migrated to Xec:

# .xec/config.yaml
defaults:
env:
CXX: g++
CXXFLAGS: -std=c++17 -Wall -Wextra
LDFLAGS: -lpthread -lssl -lcrypto

tasks:
all:
needs: [build]

build:
description: Build the C++ application
script: scripts/build.ts

test:
description: Run test suite
needs: [build]
script: scripts/test.ts

install:
description: Install to system
needs: [build]
command: install -m 755 myapp /usr/local/bin/

docker:
description: Build and push Docker image
steps:
- name: Build image
command: docker build -t myapp:latest .
- name: Push image
command: docker push myregistry/myapp:latest

clean:
description: Clean build artifacts
script: scripts/clean.ts

watch:
description: Watch and rebuild on changes
command: xec watch --pattern "src/**/*.cpp" --exec "xec build"
// scripts/build.ts
import { $, glob, fs } from '@xec-sh/core';
import path from 'path';

const CXX = process.env.CXX || 'g++';
const CXXFLAGS = process.env.CXXFLAGS || '-std=c++17 -Wall -Wextra';
const LDFLAGS = process.env.LDFLAGS || '-lpthread -lssl -lcrypto';

const SRC_DIR = 'src';
const BUILD_DIR = 'build';
const TARGET = 'myapp';

// Create build directory
await fs.mkdir(BUILD_DIR, { recursive: true });

// Compile source files
console.log('πŸ”¨ Compiling source files...');
const sources = await glob(`${SRC_DIR}/*.cpp`);

const compilePromises = sources.map(async (source) => {
const object = path.join(
BUILD_DIR,
path.basename(source, '.cpp') + '.o'
);

console.log(` Compiling ${source}...`);
await $`${CXX} ${CXXFLAGS} -c ${source} -o ${object}`;
return object;
});

const objects = await Promise.all(compilePromises);

// Link executable
console.log('πŸ”— Linking executable...');
await $`${CXX} ${objects.join(' ')} ${LDFLAGS} -o ${TARGET}`;

console.log(`βœ… Built ${TARGET} successfully!`);
// scripts/test.ts
import { $, glob } from '@xec-sh/core';
import path from 'path';

const CXX = process.env.CXX || 'g++';
const CXXFLAGS = process.env.CXXFLAGS || '-std=c++17 -Wall -Wextra';
const LDFLAGS = process.env.LDFLAGS || '-lpthread -lssl -lcrypto';

const TEST_DIR = 'test';
const BUILD_DIR = 'build';

// Build main app first
await $`xec build`;

// Compile test files
console.log('πŸ§ͺ Compiling tests...');
const testSources = await glob(`${TEST_DIR}/*.cpp`);

const testObjects = await Promise.all(
testSources.map(async (source) => {
const object = path.join(
BUILD_DIR,
'test_' + path.basename(source, '.cpp') + '.o'
);

await $`${CXX} ${CXXFLAGS} -c ${source} -o ${object}`;
return object;
})
);

// Link test runner
console.log('πŸ”— Linking test runner...');
await $`${CXX} ${testObjects.join(' ')} ${LDFLAGS} -o ${BUILD_DIR}/test_runner`;

// Run tests
console.log('πŸƒ Running tests...');
const result = await $`./${BUILD_DIR}/test_runner`.nothrow();

if (result.exitCode === 0) {
console.log('βœ… All tests passed!');
} else {
console.error('❌ Tests failed!');
process.exit(1);
}

Advanced Make Features​

1. Include Files​

Makefile:

include config.mk
-include local.mk

ifdef CUSTOM_CONFIG
include $(CUSTOM_CONFIG)
endif

Xec:

# .xec/config.yaml
import:
- ./config/base.yaml
- ./config/local.yaml
- ${env.CUSTOM_CONFIG}

2. Functions and Macros​

Makefile:

define compile_rule
$(1): $(2)
$(CXX) $(CXXFLAGS) -c $(2) -o $(1)
endef

$(foreach obj,$(OBJECTS),$(eval $(call compile_rule,$(obj),$(obj:.o=.cpp))))

Xec TypeScript:

// scripts/compile.ts
function createCompileTask(source: string, output: string) {
return $`${CXX} ${CXXFLAGS} -c ${source} -o ${output}`;
}

const compileTasks = sources.map(source => {
const output = source.replace('.cpp', '.o');
return createCompileTask(source, output);
});

await Promise.all(compileTasks);

3. Auto-Dependencies​

Makefile:

DEPS = $(OBJECTS:.o=.d)

%.d: %.cpp
$(CXX) -MM $(CXXFLAGS) $< > $@

-include $(DEPS)

Xec with File Watching:

// scripts/build-incremental.ts
import { $, watch } from '@xec-sh/core';
import { createHash } from 'crypto';
import { readFile } from 'fs/promises';

const cache = new Map<string, string>();

async function getFileHash(file: string): Promise<string> {
const content = await readFile(file);
return createHash('md5').update(content).digest('hex');
}

async function needsRebuild(source: string): Promise<boolean> {
const hash = await getFileHash(source);
const cached = cache.get(source);

if (cached !== hash) {
cache.set(source, hash);
return true;
}

return false;
}

// Only compile changed files
for (const source of sources) {
if (await needsRebuild(source)) {
console.log(`Recompiling ${source}...`);
await $`${CXX} ${CXXFLAGS} -c ${source} -o ${output}`;
}
}

Migration Strategy​

Phase 1: Analysis​

  1. Inventory Targets: List all Make targets and their purposes
  2. Map Dependencies: Document target dependencies
  3. Identify Variables: List all variables and their uses
  4. Find Patterns: Identify pattern rules and functions

Phase 2: Setup​

# Install Xec
npm install -g @xec-sh/cli

# Initialize configuration
xec new config

# Create scripts directory
mkdir -p scripts

Phase 3: Incremental Migration​

Keep Makefile, add Xec wrapper:

# Existing Makefile targets
legacy-build:
$(CC) $(CFLAGS) -o app src/*.c

# New Xec integration
xec-build:
xec build

# Transition period - both work
build: xec-build

Phase 4: Complete Migration​

  1. Convert all targets to Xec tasks
  2. Replace pattern rules with TypeScript scripts
  3. Update CI/CD pipelines
  4. Remove Makefile

Compatibility During Migration​

Wrapper Script​

#!/bin/bash
# make-compat.sh - Make compatibility wrapper

case "$1" in
build)
xec build
;;
test)
xec test
;;
clean)
xec clean
;;
*)
xec "$@"
;;
esac

Dual Support​

// package.json
{
"scripts": {
"make": "./make-compat.sh"
}
}

Benefits After Migration​

1. Cross-Platform Support​

// Works on Windows, macOS, Linux
import { platform } from 'os';

const isWindows = platform() === 'win32';
const exe = isWindows ? '.exe' : '';

await $`gcc -o myapp${exe} src/*.c`;

2. Better Error Handling​

const result = await $`gcc -o app src/*.c`.nothrow();

if (!result.ok) {
console.error('Compilation failed:', result.stderr);
// Send notification, create issue, etc.
await notifyBuildFailure(result.stderr);
}

3. Modern Development Experience​

  • TypeScript with autocomplete
  • Proper IDE support
  • Integrated debugging
  • NPM package ecosystem

4. Enhanced Parallelism​

// Compile all files in parallel with concurrency limit
import pLimit from 'p-limit';

const limit = pLimit(4); // Max 4 concurrent compilations

await Promise.all(
sources.map(source =>
limit(() => compileFile(source))
)
);

Common Migration Pitfalls​

1. Tab vs Space​

  • Make requires tabs for recipes
  • Xec uses standard YAML/TypeScript

2. Shell Differences​

  • Make uses sh by default
  • Xec provides consistent cross-platform shell

3. Variable Expansion​

  • Make: $(VAR) or ${VAR}
  • Xec: ${params.var} or TypeScript variables

4. Implicit Rules​

  • Make has built-in rules for common patterns
  • Xec requires explicit definitions (more clarity)

Summary​

Migrating from Make to Xec provides:

  • βœ… True cross-platform support
  • βœ… Modern TypeScript programming
  • βœ… Better error handling and debugging
  • βœ… Integrated remote execution (SSH/Docker/K8s)
  • βœ… Parallel execution control
  • βœ… Rich ecosystem integration

Start with simple targets and gradually migrate complex build logic to experience the full benefits of Xec's modern approach to task automation!