Status: Planned for future release (v2.0 or v1.5) Priority: Medium Estimated effort: 9-13 hours Breaking change: No (requires Node.js >=18, already required)
This document outlines the plan to migrate Termly CLI from CommonJS to ES Modules (ESM). The migration is feasible, all dependencies support ESM, and the codebase is small enough (31 files) to make this a manageable task.
Current status: Project works well with CommonJS, no urgent need to migrate. Future benefit: ESM is the modern standard, better performance, access to ESM-only packages.
| Package | Current Version | Latest Version | ESM Support | Action Required |
|---|---|---|---|---|
| axios | ^1.6.0 | 1.x | ✅ Full (dual export) | None - already compatible |
| chalk | ^4.1.2 | 5.6.2 | ✅ ESM-only from v5 | Upgrade to v5 |
| commander | ^11.0.0 | 12.x | ✅ Full (CJS compatible) | Optional upgrade |
| conf | ^10.2.0 | 15.0.2 | ✅ ESM-only from v11 | Upgrade to v15 |
| inquirer | ^8.2.5 | 12.10.0 | ✅ Dual export from v10+ | Upgrade to v12 |
| node-pty | @lydell/node-pty | 1.1.0 | Works with ESM wrapper | |
| qrcode-terminal | ^0.12.0 | 0.12.x | ✅ Dual export | None - already compatible |
| semver | ^7.7.3 | 7.x | ✅ Dual export | None - already compatible |
| uuid | ^9.0.0 | 11.x | ✅ Full | Optional upgrade to v11 |
| ws | ^8.14.0 | 8.x | ✅ Dual export | None - already compatible |
✅ All dependencies support ESM
✅ node-pty works with ESM (Node.js auto-wraps CJS modules)
✅ Only 31 files to migrate
✅ Only 2 usages of __dirname/__filename to replace
✅ 103 require() statements to convert
✅ 30 module.exports statements to convert
-
Create feature branch:
git checkout -b feature/esm-migration
-
Update dependencies:
npm install chalk@5 conf@15 inquirer@12 commander@12 uuid@11
-
Add ESM flag to package.json:
{ "type": "module", "engines": { "node": ">=18.0.0" } } -
Verify project breaks as expected (all require() will fail)
# Basic require to import conversion
find lib bin -name "*.js" -exec sed -i '' \
's/const \(.*\) = require(\(.*\));/import \1 from \2;/g' {} \;
# Convert destructuring
find lib bin -name "*.js" -exec sed -i '' \
's/const { \(.*\) } = require(\(.*\));/import { \1 } from \2;/g' {} \;# Convert module.exports =
find lib -name "*.js" -exec sed -i '' \
's/module\.exports = /export default /g' {} \;
# Convert module.exports.foo =
find lib -name "*.js" -exec sed -i '' \
's/module\.exports\.\([a-zA-Z0-9_]*\) =/export const \1 =/g' {} \;# Add .js to relative imports
find lib bin -name "*.js" -exec sed -i '' \
"s/from '\(\.\.\/[^']*\)'/from '\1.js'/g" {} \;
find lib bin -name "*.js" -exec sed -i '' \
"s/from '\(\.\/[^']*\)'/from '\1.js'/g" {} \;Note: Automated scripts will cover ~80% of the work. Manual fixes needed for edge cases.
Before (CommonJS):
const path = require('path');
const configPath = path.join(__dirname, '..', 'config.json');After (ESM):
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, '..', 'config.json');Alternative (shorter):
import { fileURLToPath } from 'url';
import { join } from 'path';
const configPath = join(
fileURLToPath(new URL('..', import.meta.url)),
'config.json'
);package.json imports - 3 options:
Option A: Import assertion (experimental but standard):
import packageJson from '../package.json' with { type: 'json' };Option B: Read + parse (most compatible):
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
const packageJson = JSON.parse(
readFileSync(
fileURLToPath(new URL('../package.json', import.meta.url)),
'utf8'
)
);Option C: Create package-info.js (cleanest):
// package-info.js (generated or manual)
export const version = '1.2.0';
export const name = '@termly-dev/cli';
// cli.js
import { version, name } from '../package-info.js';Recommendation: Use Option A for Node.js 18+, fallback to Option B if needed.
Before:
const toolName = 'claude-code';
const tool = require(`./tools/${toolName}.js`);After:
const toolName = 'claude-code';
const tool = await import(`./tools/${toolName}.js`);
// If it was export default:
const { default: tool } = await import(`./tools/${toolName}.js`);Standard import (should work):
import pty from 'node-pty';Fallback if issues arise:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pty = require('node-pty');# Test basic commands
node bin/cli.js --version
node bin/cli.js config
node bin/cli.js tools
node bin/cli.js list
# Test with debug mode
DEBUG=1 node bin/cli.js start --debug
# Test global installation
npm link
termly --version
termly config
npm unlink-
termly setup -
termly start(with auto-detection) -
termly start --ai claude-code -
termly status -
termly stop -
termly list -
termly tools -
termly config -
termly cleanup
- Start session in directory with existing session
- Stop non-existent session
- Cleanup stale sessions
- WebSocket reconnection logic
- Session resume with catchup
- Encryption/decryption flow
- QR code generation
- Version check with outdated CLI
- macOS (Intel & Apple Silicon)
- Linux (Ubuntu/Debian)
- Windows 10+ (ConPTY)
# Test package build
npm pack
tar -xzf termly-dev-cli-*.tgz
cd package
npm install -g .
termly --versionCLAUDE.md:
- Remove mentions of "CommonJS"
- Update code examples to ESM syntax
- Add ESM-specific notes (
.jsextensions required)
README.md (if exists):
- Update installation instructions
- Emphasize Node.js 18+ requirement
- Update code examples
package.json:
{
"type": "module",
"engines": {
"node": ">=18.0.0"
}
}package.dev.json:
{
"type": "module",
"engines": {
"node": ">=18.0.0"
}
}- Update GitHub Actions workflows (if any)
- Test on multiple Node.js versions (18, 20, 22)
- Update npm scripts if needed
Solution:
// ESM automatically wraps CJS modules - this works:
import pty from 'node-pty';
// Fallback if needed:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pty = require('node-pty');ESM is stricter about circular dependencies than CJS.
Detection:
npm install -g madge
madge --circular lib/Solution:
- Extract shared code to separate module
- Use dependency injection
- Refactor to break the cycle
ESM allows top-level await, but for CLI tools it's better to wrap:
Recommended pattern:
#!/usr/bin/env node
async function main() {
const config = await loadConfig();
// ... rest of CLI logic
}
main().catch(error => {
console.error(error);
process.exit(1);
});In ESM, file extensions are mandatory for local imports:
// ❌ Wrong
import { foo } from './utils';
// ✅ Correct
import { foo } from './utils.js';Tip: Use ESLint plugin eslint-plugin-import to catch this automatically.
If migration fails or introduces critical bugs:
-
Revert package.json:
git checkout main -- package.json package.dev.json
-
Reinstall dependencies:
npm install
-
Test production version:
node bin/cli.js --version
-
Publish hotfix if needed:
npm version patch npm publish
- Future-proof: ESM is the JavaScript standard (ES2015+)
- Better performance: Static analysis enables tree-shaking
- Modern packages: Access to ESM-only libraries (chalk v5+, conf v11+)
- Top-level await: Simplifies async code
- Smaller bundles: Better dead-code elimination
- IDE support: Better autocomplete and type inference
- Browser compatibility: Same module system for Node.js and browsers
- Breaking change: Requires Node.js 18+ (already required, so no issue)
- Time investment: 9-13 hours of work
- Testing overhead: Need comprehensive tests to avoid regressions
- Learning curve: Team needs to understand ESM differences
- Tooling updates: May need to update build tools, linters, etc.
- v2.0 release - Major version bump allows breaking changes
- After adding test suite - Need safety net before refactoring
- When chalk/conf updates needed - If we need features from newer versions
- Scheduled maintenance window - 2-3 days of dedicated time
- Low user impact period - Between major feature releases
- Basic test suite implemented (smoke tests minimum)
- CI/CD pipeline in place
- Backup of stable production version
- Team availability for quick fixes if needed
| Phase | Duration | Dependencies |
|---|---|---|
| Preparation | 1-2h | None |
| Automated conversion | 2-3h | Phase 1 complete |
| Manual refactoring | 3-4h | Phase 2 complete |
| Testing | 2-3h | Phase 3 complete |
| Documentation | 1h | Phase 4 complete |
| Total | 9-13h | Sequential |
Recommended schedule:
- Day 1 (4h): Phases 1-2 (preparation + automated conversion)
- Day 2 (5h): Phase 3 (manual refactoring)
- Day 3 (4h): Phases 4-5 (testing + documentation)
- Create feature branch
feature/esm-migration - Backup current stable version
- Notify team of migration in progress
- Review this document thoroughly
- Update dependencies
- Run automated conversion scripts
- Manual refactoring pass
- Fix all ESLint/linter errors
- Test all commands locally
- Test npm pack + install
- Cross-platform testing
- Update documentation
- Update CI/CD
- Create PR with detailed description
- Code review
- Merge to main
- Tag new version (v2.0.0 or v1.5.0)
- Publish to npm
- Monitor for issues
Migration is feasible and recommended for a future release. All technical blockers have been analyzed, and the path forward is clear. The main barrier is time investment, not technical complexity.
Recommended approach: Schedule this for v2.0 or v1.5, after implementing a basic test suite. The current CommonJS implementation works fine, so there's no urgency.
Document Version: 1.0 Last Updated: 2025-01-08 Author: Claude (Anthropic) Next Review Date: When planning v2.0 or v1.5 release