DEV Community

Alex Jeman
Alex Jeman

Posted on

From `node dist/index.js` to `mycli`: Transform Your Ink.js App into a Professional Command-Line Tool

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
Enter fullscreen mode Exit fullscreen mode

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]} />);
Enter fullscreen mode Exit fullscreen mode
// package.json
{
    "name": "my-cli-app",
    "scripts": {
        "build": "tsc",
        "dev": "tsc --watch"
    },
    "dependencies": {
        "ink": "^4.1.0",
        "react": "^18.2.0"
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Dependencies to add:

npm install commander
npm install -D @types/node
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Method 2: GitHub Installation

# Users can install directly from GitHub
npm install -g git+https://github.com/username/my-cli-app.git
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.json

{
    "extends": "@sindresorhus/tsconfig",
    "compilerOptions": {
        "outDir": "dist",
        "rootDir": "src",
        "module": "ESNext",
        "target": "ES2020",
        "moduleResolution": "node",
        "allowSyntheticDefaultImports": true,
        "jsx": "react-jsx"
    },
    "include": ["src/**/*"]
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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!');
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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)"');
    });
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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
    });
Enter fullscreen mode Exit fullscreen mode

Conclusion

Transforming your Ink.js application from node dist/index.js to a professional CLI tool like mycli involves several key steps:

  1. Add the shebang line to make your script executable
  2. Configure package.json with proper bin and metadata
  3. Use npm link for local development and testing
  4. Add proper argument parsing with Commander.js
  5. Implement error handling and user-friendly messages
  6. Choose your distribution method (npm, GitHub, or binaries)
  7. 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)