A complete guide to packaging your React-based terminal applications for easy distribution and user-friendly execution
The Problem: Nobody Wants to Type node dist/index.js
You've built an amazing terminal application with Ink.js and React. It works perfectly when you run node dist/index.js
, but asking users to remember and type that every time? That's a recipe for abandonment.
# What users have to do (painful)
$ node dist/index.js --help
$ node dist/index.js chat --model=gpt-4
# What users want to do (simple)
$ mycli --help
$ mycli chat --model=gpt-4
This guide will show you exactly how to transform your Ink.js project from a development prototype into a professional, distributable command-line tool that users can install and run with a simple command.
The Journey: From Development to Distribution
Phase 1: The Typical Ink.js Setup
Most Ink.js projects start like this - a simple React-based terminal application:
// src/index.ts
#!/usr/bin/env node
import React from 'react';
import {render, Text} from 'ink';
const App = ({name = 'World'}) => (
<Text>Hello, <Text color="green">{name}</Text>!</Text>
);
render(<App name={process.argv[2]} />);
// package.json
{
"name": "my-cli-app",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"ink": "^4.1.0",
"react": "^18.2.0"
}
}
This works great for development, but users have to run node dist/index.js
every time. Not exactly user-friendly.
Phase 2: Making It Executable
The first step is making your compiled JavaScript executable. This requires two key changes:
1. Add the Shebang Line
// src/index.ts
#!/usr/bin/env node // <- This line is crucial!
import React from 'react';
import {render, Text} from 'ink';
// ... rest of your code
The shebang (#!/usr/bin/env node
) tells the system to run this file with Node.js when executed directly.
2. Configure package.json
{
"name": "my-cli-app",
"version": "1.0.0",
"bin": "dist/index.js", // <- Points to your compiled entry point
"type": "module", // <- Enable ES modules (if using them)
"engines": {
"node": ">=16" // <- Specify minimum Node.js version
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
}
}
Key Configuration:
-
"bin": "dist/index.js"
- Points to your compiled entry point -
"type": "module"
- Enables ES modules (if you're using import/export) -
"engines"
- Specifies Node.js version requirements
Phase 3: Local Development Setup
Now you can test your CLI locally using npm link
:
# Build your project
npm run build
# Create a global symlink
npm link
# Now you can run your CLI directly!
mycli --help
mycli "Developer"
What npm link
does:
- Creates a symlink in your global npm directory
- Points to your local project
- Makes your CLI available system-wide during development
Phase 4: Advanced CLI Features
For a professional CLI, you'll want proper argument parsing. Here's how to add it:
// src/index.ts
#!/usr/bin/env node
import React from 'react';
import {render} from 'ink';
import {Command} from 'commander';
import App from './App.js';
const program = new Command();
program
.name('mycli')
.description('My awesome CLI tool')
.version('1.0.0');
program
.command('greet')
.description('Greet someone')
.option('-n, --name <name>', 'Name to greet', 'World')
.action((options) => {
render(<App name={options.name} />);
});
program
.command('chat')
.description('Start chat mode')
.option('-m, --model <model>', 'AI model to use', 'gpt-4')
.action((options) => {
render(<ChatApp model={options.model} />);
});
// Default action when no command specified
if (process.argv.length === 2) {
render(<App name="World" />);
} else {
program.parse();
}
Dependencies to add:
npm install commander
npm install -D @types/node
Phase 5: Distribution Methods
Once your CLI is ready, you have several distribution options:
Method 1: npm Registry (Recommended)
# Publish to npm (public)
npm publish
# Users install with:
npm install -g my-cli-app
Method 2: GitHub Installation
# Users can install directly from GitHub
npm install -g git+https://github.com/username/my-cli-app.git
Method 3: Standalone Binaries
For users without Node.js, create standalone executables:
# Install pkg
npm install -g pkg
# Create binaries for all platforms
pkg package.json
# Or specific platforms
pkg -t node18-linux-x64,node18-macos-x64,node18-win-x64 .
Phase 6: Professional Polish
Error Handling
// src/index.ts
process.on('uncaughtException', error => {
console.error('❌ Unexpected error:', error.message);
process.exit(1);
});
process.on('unhandledRejection', reason => {
console.error('❌ Unhandled promise rejection:', reason);
process.exit(1);
});
Update Notifications
import updateNotifier from 'update-notifier';
import packageJson from '../package.json';
const notifier = updateNotifier({
pkg: packageJson,
updateCheckInterval: 1000 * 60 * 60 * 24, // Check daily
});
notifier.notify();
Configuration Management
// src/config.ts
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const configDir = path.join(os.homedir(), '.mycli');
const configFile = path.join(configDir, 'config.json');
export function loadConfig() {
try {
return JSON.parse(fs.readFileSync(configFile, 'utf8'));
} catch {
return {};
}
}
export function saveConfig(config: any) {
fs.mkdirSync(configDir, {recursive: true});
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
}
Real-World Example: Complete Setup
Here's a complete example of a professional Ink.js CLI setup:
Project Structure
my-cli-app/
├── src/
│ ├── index.ts # CLI entry point
│ ├── App.tsx # Main React component
│ ├── commands/ # Command implementations
│ └── config/ # Configuration management
├── package.json
├── tsconfig.json
└── README.md
package.json
{
"name": "my-cli-app",
"version": "1.0.0",
"description": "My awesome CLI tool built with Ink.js",
"bin": "dist/index.js",
"type": "module",
"engines": {
"node": ">=16"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"prepublishOnly": "npm run build"
},
"keywords": ["cli", "terminal", "ink", "react"],
"dependencies": {
"ink": "^4.1.0",
"react": "^18.2.0",
"commander": "^9.4.0"
},
"devDependencies": {
"@types/node": "^18.0.0",
"@types/react": "^18.0.0",
"typescript": "^4.8.0"
}
}
tsconfig.json
{
"extends": "@sindresorhus/tsconfig",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"]
}
Complete CLI Entry Point
// src/index.ts
#!/usr/bin/env node
import React from 'react';
import {render} from 'ink';
import {Command} from 'commander';
import App from './App.js';
const program = new Command();
program
.name('mycli')
.description('My awesome CLI tool')
.version('1.0.0');
program
.command('greet')
.description('Greet someone')
.option('-n, --name <name>', 'Name to greet', 'World')
.action((options) => {
render(<App name={options.name} />);
});
// Handle default action
if (process.argv.length === 2) {
render(<App name="World" />);
} else {
program.parse();
}
Testing Your CLI
Local Testing
# Build and link
npm run build
npm link
# Test commands
mycli --help
mycli greet --name="Developer"
mycli greet -n "React Fan"
Automated Testing
// test/cli.test.ts
import {render} from 'ink-testing-library';
import App from '../src/App.js';
test('renders greeting', () => {
const {lastFrame} = render(<App name="Test" />);
expect(lastFrame()).toContain('Hello, Test!');
});
Distribution Checklist
Before publishing your CLI, ensure you have:
- [ ] Shebang line in your entry point
- [ ] Correct bin configuration in package.json
- [ ] Version specified and follows semver
- [ ] Node.js version requirement specified
- [ ] README with installation instructions
- [ ] Help text and usage examples
- [ ] Error handling for common issues
- [ ] Tests for core functionality
- [ ] Keywords for npm discoverability
Common Pitfalls and Solutions
Issue 1: "Command not found" after npm link
Solution: Check that your bin
path in package.json points to the correct compiled file.
Issue 2: "Permission denied" on execution
Solution: Ensure your compiled JavaScript file has the shebang line and is executable:
chmod +x dist/index.js
Issue 3: Module resolution errors
Solution: Make sure your import paths use .js
extensions for compiled output:
// Use this
import App from './App.js';
// Not this
import App from './App';
Issue 4: TypeScript compilation issues
Solution: Configure tsconfig.json properly for Node.js and React:
{
"compilerOptions": {
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "node",
"jsx": "react-jsx"
}
}
Advanced Features
Auto-completion
Add shell completion for better UX:
// Add to your CLI
program
.command('completion')
.description('Generate shell completion script')
.action(() => {
console.log('# Add this to your shell profile:');
console.log('eval "$(mycli completion)"');
});
Progress Indicators
For long-running operations:
import {render} from 'ink';
import Spinner from 'ink-spinner';
const LoadingApp = () => (
<Text>
<Text color="green">
<Spinner type="dots" />
</Text>
{' Processing...'}
</Text>
);
Configuration Commands
Let users manage settings:
program
.command('config')
.description('Manage configuration')
.option('--set <key=value>', 'Set configuration value')
.option('--get <key>', 'Get configuration value')
.action(options => {
// Handle config management
});
Conclusion
Transforming your Ink.js application from node dist/index.js
to a professional CLI tool like mycli
involves several key steps:
- Add the shebang line to make your script executable
- Configure package.json with proper bin and metadata
- Use npm link for local development and testing
- Add proper argument parsing with Commander.js
- Implement error handling and user-friendly messages
- Choose your distribution method (npm, GitHub, or binaries)
- Polish with professional features like auto-updates and config management
The result is a CLI tool that users can install with npm install -g
and run with a simple command. No more asking users to remember complex Node.js invocations!
Your Ink.js application deserves to be as easy to run as it is powerful to use. With these techniques, you can create CLI tools that developers actually want to install and use.
Ready to transform your Ink.js project? Start with the shebang line and package.json configuration, then work your way through the advanced features. Your users will thank you for the improved experience!
Tags: #Ink.js #CLI #Node.js #React #Terminal #CommandLine #npm #Distribution
Top comments (0)