betterpack
Version:
A Universal Node.js Package Manager CLI with automated agent capabilities
665 lines (598 loc) • 20.5 kB
JavaScript
#!/usr/bin/env node
const { spawn, spawnSync } = require("child_process")
const fs = require("fs")
const path = require("path")
const availablePmPaths = {} // New global variable
const PACKAGE_MANAGERS = {
npm: {
commands: {
install: "install",
add: "install",
remove: "uninstall",
update: "update",
run: "run",
test: "test",
build: "build",
list: "list",
pack: "pack",
cache: { cmd: "cache", args: ["clean", "--force"] },
ci: "ci",
doctor: "doctor",
link: "link",
global: { cmd: "list", args: ["-g"] },
outdated: "outdated",
search: "search",
start: "start",
audit: "audit",
"audit:fix": { cmd: "audit", args: ["fix"] },
lint: { cmd: "run", args: ["lint"] },
"lint:fix": { cmd: "run", args: ["lint", "--", "--fix"] },
},
},
yarn: {
commands: {
install: "install",
add: "add",
remove: "remove",
update: "upgrade",
run: "run",
test: "test",
build: "build",
list: "list",
pack: "pack",
cache: { cmd: "cache", args: ["clean"] },
ci: { cmd: "install", args: ["--immutable"] },
doctor: "doctor",
link: "link",
global: { cmd: "global", args: ["list"] },
outdated: "outdated",
search: null,
start: "start",
audit: { cmd: "npm", args: ["audit"] },
"audit:fix": { cmd: "npm", args: ["audit", "fix"] },
lint: "lint",
"lint:fix": { cmd: "lint", args: ["--fix"] },
},
},
pnpm: {
commands: {
install: "install",
add: "add",
remove: "remove",
update: "update",
run: "run",
test: "test",
build: "build",
list: "list",
pack: "pack",
cache: { cmd: "store", args: ["prune"] },
ci: { cmd: "install", args: ["--frozen-lockfile"] },
doctor: "doctor",
link: "link",
global: { cmd: "list", args: ["-g"] },
outdated: "outdated",
search: null,
start: "start",
audit: "audit",
"audit:fix": { cmd: "audit", args: ["--fix"] },
lint: "lint",
"lint:fix": { cmd: "lint", args: ["--fix"] },
},
},
bun: {
commands: {
install: "install",
add: "add",
remove: "remove",
update: "upgrade",
run: "run",
test: "test",
build: "build",
list: "list",
pack: "pack",
cache: { cmd: "cache", args: ["clean"] },
ci: { cmd: "install", args: ["--frozen-lockfile"] },
doctor: "doctor",
link: "link",
global: { cmd: "list", args: ["-g"] },
outdated: "outdated",
search: null,
start: "start",
audit: "audit",
"audit:fix": { cmd: "audit", args: ["--fix"] },
lint: "lint",
"lint:fix": { cmd: "lint", args: ["--fix"] },
},
},
}
const GLOBAL_COMMANDS = {
npm: { install: ["install", "-g"], update: ["update", "-g"], remove: ["uninstall", "-g"], list: ["list", "-g"] },
yarn: {
install: ["global", "add"],
update: ["global", "upgrade"],
remove: ["global", "remove"],
list: ["global", "list"],
},
pnpm: { install: ["add", "-g"], update: ["update", "-g"], remove: ["remove", "-g"], list: ["list", "-g"] },
bun: { install: ["add", "-g"], update: ["update", "-g"], remove: ["remove", "-g"], list: ["ls", "-g"] },
}
function getAvailablePms() {
const allPms = ["npm", "yarn", "pnpm", "bun"]
const executablesToFind = [...allPms, "node", "http-server", "npx"] // Add node, http-server, and npx
const availablePms = []
for (const pm of executablesToFind) {
// Iterate through all executables
// Use 'where' on Windows or 'which' on Linux/macOS to find the executable path
const findCmd = process.platform === "win32" ? "where" : "which"
const result = spawnSync(findCmd, [pm], { shell: true, encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const pmPath = result.stdout.trim().replace(/\r/g, "").split("\n")[0] // Get the first path if multiple and remove carriage returns
// On Windows, if the path doesn't have an extension, check for .cmd
if (allPms.includes(pm)) {
// Only add package managers to availablePms list
availablePms.push(pm)
}
availablePmPaths[pm] = pmPath // Store the absolute path for all found executables
} else {
// Fallback to just checking --version if path not found (e.g., for built-in npm)
// This part might need refinement for non-PM executables
const versionResult = spawnSync(pm, ["--version"], { shell: true, encoding: "utf8" })
if (versionResult.status === 0) {
if (allPms.includes(pm)) {
availablePms.push(pm)
}
availablePmPaths[pm] = pm // Store just the name, rely on shell for execution
}
}
}
return availablePms
}
function detectPackageManager() {
const cwd = process.cwd()
const packageJsonPath = path.join(cwd, "package.json")
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
if (packageJson.packageManager) {
const pm = packageJson.packageManager.split("@")[0]
if (PACKAGE_MANAGERS[pm]) {
return pm
}
}
} catch (e) {
// Ignore error
}
}
if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm"
if (fs.existsSync(path.join(cwd, "yarn.lock"))) return "yarn"
if (fs.existsSync(path.join(cwd, "package-lock.json"))) return "npm"
// Default to pnpm if no lockfile or packageManager field is found
return "pnpm"
}
function translateCommand(pmName, universalCommand, args) {
if (!PACKAGE_MANAGERS[pmName]) {
return null
}
const pmConfig = PACKAGE_MANAGERS[pmName]
const translated = pmConfig.commands[universalCommand]
if (translated === undefined) {
console.warn(
`Warning: Universal command '${universalCommand}' is not explicitly defined for ${pmName}. Attempting to pass through.`,
)
return [pmName, universalCommand, ...args]
}
if (translated === null) {
console.error(`Error: Command '${universalCommand}' is not supported by ${pmName}.`)
if (universalCommand === "search") {
console.error("Suggestion: Try 'bpack manage npm search <query>' to use npm's search feature.")
}
return null
}
if (typeof translated === "string") {
return [pmName, translated, ...args]
}
if (typeof translated === "object") {
const finalArgs = [...translated.args, ...args]
return [pmName, translated.cmd, ...finalArgs]
}
return [pmName, universalCommand, ...args]
}
function findPathKey(env = process.env) {
if (process.platform !== "win32") {
return "PATH"
}
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path")
return pathKey || "PATH"
}
function executeCommand(command, args, options = {}) {
if (!options.silent) {
console.log(`Executing: ${command} ${args.join(" ")}`)
}
const env = { ...process.env }
const pathKey = findPathKey(env)
const localBinPath = path.join(__dirname, "..", "node_modules", ".bin")
// Prepend local bin path
env[pathKey] = [localBinPath, env[pathKey]].filter(Boolean).join(path.delimiter)
const spawnOptions = {
stdio: "inherit",
shell: true,
env: env,
}
console.log(`executeCommand: Spawning command: ${command} with args: ${args.join(" ")}`)
const child = spawn(command, args, spawnOptions)
child.on("error", (err) => {
console.error(`Failed to start subprocess: ${err.message}`)
})
}
function executeOutdatedCommand(command, args) {
console.log(`Executing: ${command} ${args.join(" ")}`)
const env = { ...process.env }
const pathKey = findPathKey(env)
const localBinPath = path.join(__dirname, "..", "node_modules", ".bin")
// Prepend local bin path
env[pathKey] = [localBinPath, env[pathKey]].filter(Boolean).join(path.delimiter)
const spawnOptions = {
stdio: "pipe",
shell: true,
env: env,
}
const child = spawn(command, args, spawnOptions)
let stdout = ""
let stderr = ""
if (child.stdout) {
child.stdout.on("data", (data) => {
stdout += data.toString()
})
}
if (child.stderr) {
child.stderr.on("data", (data) => {
stderr += data.toString()
})
}
child.on("close", (code) => {
if (code === 0 && stdout.trim() === "" && stderr.trim() === "") {
console.log("All dependencies are up to date.")
} else {
if (stdout.trim()) {
console.log(stdout.trim())
}
if (stderr.trim()) {
console.error(stderr.trim())
}
}
})
child.on("error", (err) => {
console.error(`Failed to start subprocess: ${err.message}`)
})
}
function listversions() {
const scriptPath = path.join(__dirname, "..", "detectpkgmgr.ps1")
if (process.platform === "win32") {
executeCommand("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
silent: true,
})
} else {
console.log("'listversions' is only supported on Windows.")
process.exit(1)
}
}
function manageCommand(args) {
if (args[0] === "selfupdate") {
console.log("Attempting to self-update betterpack...")
const pmsToCheck = {
npm: ["list", "-g", "--depth=0"],
pnpm: ["list", "-g", "--depth=0"],
yarn: ["global", "list"],
}
let installedWith = null
for (const pm in pmsToCheck) {
const result = spawnSync(pm, pmsToCheck[pm], { shell: true, encoding: "utf8" })
if (result.status === 0 && result.stdout && result.stdout.includes("betterpack")) {
installedWith = pm
break
}
}
if (installedWith) {
console.log(`betterpack was installed with ${installedWith}. Attempting update...`)
let updateArgs
switch (installedWith) {
case "npm":
updateArgs = ["install", "-g", "betterpack@latest"]
break
case "pnpm":
updateArgs = ["update", "-g", "betterpack"]
break
case "yarn":
updateArgs = ["global", "upgrade", "betterpack"]
break
}
executeCommand(installedWith, updateArgs)
} else {
console.error("Could not determine how betterpack was installed globally. Please update manually.")
process.exit(1)
}
return
}
if (args[0] === "about") {
const topic = args[1]
let url
switch (topic) {
case "github":
url = "https://github.com/involvex/betterpack"
break
case "npmjs":
url = "https://www.npmjs.com/package/betterpack"
break
default:
console.error("Usage: bpack manage about <github|npmjs>")
process.exit(1)
}
console.log(`Opening ${url}...`)
const openCmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open"
executeCommand(openCmd, [url], { silent: true })
return
}
if (args[0] === "support") {
const url = "https://www.buymeacoffee.com/involvex"
console.log(`Opening ${url}...`)
const openCmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open"
executeCommand(openCmd, [url], { silent: true })
return
}
if (args[0] === "git" && args[1] === "autocommit") {
const status = args[2]
if (status !== "on" && status !== "off") {
console.error("Usage: bpack manage git autocommit <on|off>")
process.exit(1)
}
const configPath = path.join(require("os").homedir(), ".bpackrc")
let config = {}
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, "utf8"))
}
config.autocommit = status === "on"
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
console.log(`Autocommit is now ${status}.`)
return
}
const [pkgMgr, action, ...rest] = args
const availablePms = getAvailablePms()
if (!pkgMgr || !action) {
console.log("Usage: bpack manage <package-manager> <action> [args]")
console.log(" bpack manage selfupdate")
console.log(" bpack manage about <github|npmjs>")
console.log(" bpack manage git autocommit <on|off>")
console.log(` Package Managers: ${availablePms.join(", ")}`)
console.log(" Actions: install, update, remove, version, list")
console.log(" Note: 'update' with no package name will attempt to update the manager itself.")
process.exit(1)
}
if (!availablePms.includes(pkgMgr)) {
console.error(`Error: Package manager '${pkgMgr}' not found. Available: ${availablePms.join(", ")}`)
process.exit(1)
}
// Special handling for updating the package manager itself
if (action === "update" && rest.length === 0) {
const updateCmd = pkgMgr
let updateArgs = []
switch (pkgMgr) {
case "npm":
updateArgs = ["install", "-g", "npm@latest"]
break
case "pnpm":
updateArgs = ["self-update"]
break
case "bun":
updateArgs = ["upgrade"]
break
case "yarn":
console.log("Attempting to set Yarn version for current project...")
updateArgs = ["set", "version", "stable"]
break
}
executeCommand(updateCmd, updateArgs)
return
}
if (action === "version") {
executeCommand(pkgMgr, ["--version"])
return
}
const baseArgs = GLOBAL_COMMANDS[pkgMgr][action]
if (!baseArgs) {
console.error(`Error: Unknown action '${action}'. Supported: install, update, remove, version, list`)
process.exit(1)
}
const cmdArgs = [...baseArgs, ...rest]
executeCommand(pkgMgr, cmdArgs)
}
function displayHelp() {
console.log("\nUsage: bpack <command> [args]")
console.log("\n\x1b[1mUniversal Package Manager Commands:\x1b[0m")
const universalCommands = {
install: "Install project dependencies.",
add: "Add a new dependency to the project.",
remove: "Remove a dependency from the project.",
update: "Update project dependencies.",
outdated: "Check for outdated dependencies.",
ci: "Install dependencies from a lockfile.",
run: "Run a script defined in package.json.",
test: "Run project tests.",
build: "Build the project.",
start: "Start the project.",
pack: "Create a package tarball.",
list: "List installed packages.",
global: "List global packages.",
"cache clean": "Clear the package manager cache.",
doctor: "Run a health check.",
link: "Link a local package.",
search: "Search for packages (uses npm).",
"lint, lint --fix": "Run linter.",
"audit, audit --fix": "Run security audit.",
fix: "Alias for 'audit --fix'.",
}
for (const [cmd, desc] of Object.entries(universalCommands)) {
console.log(` \x1b[36m${cmd.padEnd(20)}\x1b[0m ${desc}`)
}
console.log("\n\x1b[1mBetterpack Commands:\x1b[0m")
const betterpackCommands = {
listversions: "List installed versions of all supported package managers.",
manage: "Manage global packages or the package managers themselves.",
buildexe: "Package project into an executable using nexe.",
bundle: "Bundle project into various formats (JS, ZIP, TAR, EXE, MSIX, SH).",
node: "Execute a JavaScript file using Node.js.",
watch: "Watch for file changes and auto-restart a process.",
host: "Start a web server from the current folder (default port 4020).",
gemini: "Start a chat with Gemini.",
create: "Interactive project builder with AI assistance.",
"build-project": "Alias for create - Interactive project builder with AI assistance.",
agent: "Run the betterpack agent (coming soon).",
}
for (const [cmd, desc] of Object.entries(betterpackCommands)) {
console.log(` \x1b[36m${cmd.padEnd(20)}\x1b[0m ${desc}`)
}
console.log("")
process.exit(0)
}
function runCli() {
let args = process.argv.slice(2)
getAvailablePms() // Populate availablePmPaths at the start
const configPath = path.join(require("os").homedir(), ".bpackrc")
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"))
if (config[args[0]]) {
args = config[args[0]].split(" ")
}
}
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
displayHelp()
return
}
let universalCommand = args[0]
const commandArgs = args.slice(1)
const internalCommands = [
"listversions",
"manage",
"buildexe",
"bundle",
"node",
"watch",
"host",
"gemini",
"create",
"build-project",
"agent",
]
if (commandArgs.includes("--help") || commandArgs.includes("-h")) {
displayHelp()
return
}
// --- Internal Command Dispatcher ---
if (internalCommands.includes(universalCommand)) {
switch (universalCommand) {
case "listversions":
listversions()
return
case "manage":
manageCommand(commandArgs)
return
case "bundle":
const { handleBundleCommand } = require("./bundler")
handleBundleCommand(commandArgs)
return
case "create":
case "build-project":
const { InteractiveProjectBuilder } = require("./interactive-builder")
const builder = new InteractiveProjectBuilder()
builder
.start()
.then(() => {
builder.close()
process.exit(0)
})
.catch((error) => {
console.error("❌ Error in interactive builder:", error.message)
builder.close()
process.exit(1)
})
return
case "agent":
const { runAgentCli } = require("./agent/index.cjs")
runAgentCli(commandArgs)
return
case "node":
if (commandArgs.length === 0) {
console.error("Error: Please specify a file to execute.")
process.exit(1)
}
executeCommand("node", commandArgs)
return
case "watch":
if (commandArgs.length === 0) {
console.error("Error: Please specify a command to run.")
console.error("Usage: bpack watch <command>")
process.exit(1)
}
const chokidar = require("chokidar")
const commandToRun = commandArgs.join(" ")
console.log(`Watching for file changes to run: ${commandToRun}`)
const watcher = chokidar.watch(".", {
ignored: /(^|[\/\\])\..*|node_modules|dist/, // ignore dotfiles, node_modules, dist
persistent: true,
})
let childProcess
const runCommand = () => {
if (childProcess) {
childProcess.kill()
}
console.log(`Running: ${commandToRun}`)
const [cmd, ...args] = commandToRun.split(" ")
childProcess = spawn(cmd, args, { stdio: "inherit", shell: true })
}
watcher.on("change", (path) => {
console.log(`File ${path} has been changed. Restarting...`)
runCommand()
})
runCommand() // Run once on start
return
case "host":
const portIndex = commandArgs.findIndex((arg) => arg === "-p" || arg === "--port")
const port = portIndex !== -1 ? commandArgs[portIndex + 1] : "4020"
const otherArgs = commandArgs.filter(
(arg, i) => ![portIndex, portIndex + 1].includes(i),
)
console.log(`Starting server at http://localhost:${port}`)
executeCommand("npx", ["http-server", ".", "-p", port, ...otherArgs])
return
default:
console.log(`Command '${universalCommand}' is recognized as an internal command but not yet implemented.`)
return
}
}
// --- Universal PM Command Handling ---
// Handle aliases and flags
if (universalCommand === "fix") {
universalCommand = "audit:fix"
} else {
const fixIndex = commandArgs.indexOf("--fix")
if (fixIndex !== -1) {
if (universalCommand === "audit" || universalCommand === "lint") {
universalCommand += ":fix"
commandArgs.splice(fixIndex, 1)
}
}
}
const pmName = detectPackageManager()
console.log(`Detected package manager: ${pmName}`)
const translatedCommand = translateCommand(pmName, universalCommand, commandArgs)
if (translatedCommand) {
const [cmd, ...cmdArgs] = translatedCommand
if (universalCommand === "outdated") {
executeOutdatedCommand(cmd, cmdArgs)
} else {
executeCommand(cmd, cmdArgs)
}
} else {
process.exit(1)
}
}
module.exports = { runCli, detectPackageManager, translateCommand, executeCommand, listversions, manageCommand }