|
| 1 | +#!/usr/bin/env node |
| 2 | +import { spawn } from 'node:child_process' |
| 3 | +import fs from 'node:fs/promises' |
| 4 | +import path from 'node:path' |
| 5 | +import process from 'node:process' |
| 6 | + |
| 7 | +const DEFAULT_REPO = 'sonofmagic/monorepo-template' |
| 8 | +const DEFAULT_BRANCH = 'main' |
| 9 | + |
| 10 | +function printHelp() { |
| 11 | + console.log([ |
| 12 | + 'Usage: create-icebreaker [dir] [--repo <repo>] [--branch <branch>]', |
| 13 | + 'Options:', |
| 14 | + ' --repo <repo> GitHub repo or git url to clone (default sonofmagic/monorepo-template)', |
| 15 | + ' --branch <branch> Branch or tag to checkout (default main)', |
| 16 | + ' --no-clean Skip running monorepo clean after download', |
| 17 | + ' --include-private Run clean with private packages included', |
| 18 | + ' --force Remove existing target directory before cloning', |
| 19 | + ' --agent <pnpm|npm> Force package manager used for cleanup (default auto-detect)', |
| 20 | + ' -h, --help Show this help message', |
| 21 | + ].join('\n')) |
| 22 | +} |
| 23 | + |
| 24 | +function parseArgs(argv) { |
| 25 | + const options = { |
| 26 | + targetDir: 'icebreaker-monorepo', |
| 27 | + repo: DEFAULT_REPO, |
| 28 | + branch: DEFAULT_BRANCH, |
| 29 | + clean: true, |
| 30 | + force: false, |
| 31 | + includePrivate: false, |
| 32 | + agent: undefined, |
| 33 | + } |
| 34 | + const positionals = [] |
| 35 | + for (let i = 0; i < argv.length; i++) { |
| 36 | + const arg = argv[i] |
| 37 | + if (arg === '--repo' && argv[i + 1]) { |
| 38 | + options.repo = argv[++i] |
| 39 | + continue |
| 40 | + } |
| 41 | + if (arg.startsWith('--repo=')) { |
| 42 | + options.repo = arg.split('=')[1] || options.repo |
| 43 | + continue |
| 44 | + } |
| 45 | + if (arg === '--branch' && argv[i + 1]) { |
| 46 | + options.branch = argv[++i] |
| 47 | + continue |
| 48 | + } |
| 49 | + if (arg.startsWith('--branch=')) { |
| 50 | + options.branch = arg.split('=')[1] || options.branch |
| 51 | + continue |
| 52 | + } |
| 53 | + if (arg === '--no-clean') { |
| 54 | + options.clean = false |
| 55 | + continue |
| 56 | + } |
| 57 | + if (arg === '--clean') { |
| 58 | + options.clean = true |
| 59 | + continue |
| 60 | + } |
| 61 | + if (arg === '--include-private') { |
| 62 | + options.includePrivate = true |
| 63 | + continue |
| 64 | + } |
| 65 | + if (arg === '--force') { |
| 66 | + options.force = true |
| 67 | + continue |
| 68 | + } |
| 69 | + if (arg === '--agent' && argv[i + 1]) { |
| 70 | + options.agent = argv[++i] |
| 71 | + continue |
| 72 | + } |
| 73 | + if (arg.startsWith('--agent=')) { |
| 74 | + options.agent = arg.split('=')[1] || options.agent |
| 75 | + continue |
| 76 | + } |
| 77 | + if (arg === '-h' || arg === '--help') { |
| 78 | + options.help = true |
| 79 | + continue |
| 80 | + } |
| 81 | + if (!arg.startsWith('-')) { |
| 82 | + positionals.push(arg) |
| 83 | + } |
| 84 | + } |
| 85 | + if (positionals.length) { |
| 86 | + options.targetDir = positionals[0] |
| 87 | + } |
| 88 | + return options |
| 89 | +} |
| 90 | + |
| 91 | +function normalizeRepo(repo) { |
| 92 | + if (repo.startsWith('http')) { |
| 93 | + return repo |
| 94 | + } |
| 95 | + if (repo.startsWith('gh:')) { |
| 96 | + return `https://github.com/${repo.slice(3)}.git` |
| 97 | + } |
| 98 | + if (/^[\w.-]+\/[\w.-]+$/.test(repo)) { |
| 99 | + return `https://github.com/${repo}.git` |
| 100 | + } |
| 101 | + return repo |
| 102 | +} |
| 103 | + |
| 104 | +async function isEmptyDir(dir) { |
| 105 | + try { |
| 106 | + const entries = await fs.readdir(dir) |
| 107 | + return entries.length === 0 |
| 108 | + } |
| 109 | + catch (error) { |
| 110 | + if (error && error.code === 'ENOENT') { |
| 111 | + return true |
| 112 | + } |
| 113 | + throw error |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +async function prepareTarget(dir, force) { |
| 118 | + const empty = await isEmptyDir(dir) |
| 119 | + if (empty) { |
| 120 | + await fs.mkdir(dir, { recursive: true }) |
| 121 | + return |
| 122 | + } |
| 123 | + if (!force) { |
| 124 | + throw new Error(`Target directory ${dir} is not empty. Pass --force to overwrite.`) |
| 125 | + } |
| 126 | + await fs.rm(dir, { recursive: true, force: true }) |
| 127 | + await fs.mkdir(dir, { recursive: true }) |
| 128 | +} |
| 129 | + |
| 130 | +function runCommand(command, args, options) { |
| 131 | + return new Promise((resolve, reject) => { |
| 132 | + const child = spawn(command, args, { |
| 133 | + stdio: 'inherit', |
| 134 | + ...options, |
| 135 | + }) |
| 136 | + child.on('error', reject) |
| 137 | + child.on('close', (code) => { |
| 138 | + if (code && code !== 0) { |
| 139 | + reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`)) |
| 140 | + return |
| 141 | + } |
| 142 | + resolve() |
| 143 | + }) |
| 144 | + }) |
| 145 | +} |
| 146 | + |
| 147 | +async function cloneRepo(repo, branch, targetDir) { |
| 148 | + const normalized = normalizeRepo(repo) |
| 149 | + console.log(`Cloning ${normalized} (branch ${branch})...`) |
| 150 | + await runCommand('git', ['clone', '--depth', '1', '--branch', branch, normalized, targetDir]) |
| 151 | + await fs.rm(path.join(targetDir, '.git'), { recursive: true, force: true }) |
| 152 | +} |
| 153 | + |
| 154 | +function detectAgent(userAgent) { |
| 155 | + if (!userAgent) { |
| 156 | + return 'pnpm' |
| 157 | + } |
| 158 | + const first = userAgent.split(' ')[0] || '' |
| 159 | + if (first.startsWith('pnpm/')) { |
| 160 | + return 'pnpm' |
| 161 | + } |
| 162 | + if (first.startsWith('npm/')) { |
| 163 | + return 'npm' |
| 164 | + } |
| 165 | + return 'pnpm' |
| 166 | +} |
| 167 | + |
| 168 | +function getCleanCommand(agent, includePrivate) { |
| 169 | + const usePnpm = agent === 'pnpm' |
| 170 | + const runner = usePnpm ? 'pnpm' : 'npx' |
| 171 | + const args = usePnpm |
| 172 | + ? ['dlx', '@icebreakers/monorepo@latest', 'clean', '--yes'] |
| 173 | + : ['--yes', '@icebreakers/monorepo@latest', 'clean', '--yes'] |
| 174 | + if (includePrivate) { |
| 175 | + args.push('--include-private') |
| 176 | + } |
| 177 | + return { runner, args } |
| 178 | +} |
| 179 | + |
| 180 | +async function runClean(targetDir, agent, includePrivate) { |
| 181 | + const { runner, args } = getCleanCommand(agent, includePrivate) |
| 182 | + console.log(`Running ${runner} ${args.join(' ')} in ${targetDir}`) |
| 183 | + await runCommand(runner, args, { cwd: targetDir }) |
| 184 | +} |
| 185 | + |
| 186 | +function printNextSteps(targetDir, cleanRan) { |
| 187 | + const relative = path.relative(process.cwd(), targetDir) || '.' |
| 188 | + console.log('\nAll set! Next steps:') |
| 189 | + console.log(` cd ${relative}`) |
| 190 | + if (!cleanRan) { |
| 191 | + console.log(' pnpm dlx @icebreakers/monorepo@latest clean --yes') |
| 192 | + } |
| 193 | + console.log(' pnpm install') |
| 194 | + console.log(' pnpm dev') |
| 195 | +} |
| 196 | + |
| 197 | +async function main() { |
| 198 | + const parsed = parseArgs(process.argv.slice(2)) |
| 199 | + if (parsed.help) { |
| 200 | + printHelp() |
| 201 | + return |
| 202 | + } |
| 203 | + const targetDir = path.resolve(process.cwd(), parsed.targetDir) |
| 204 | + try { |
| 205 | + await prepareTarget(targetDir, parsed.force) |
| 206 | + await cloneRepo(parsed.repo, parsed.branch, targetDir) |
| 207 | + if (parsed.clean) { |
| 208 | + const agent = parsed.agent || detectAgent(process.env.npm_config_user_agent) |
| 209 | + await runClean(targetDir, agent, parsed.includePrivate) |
| 210 | + } |
| 211 | + else { |
| 212 | + console.log('Skip clean step per --no-clean') |
| 213 | + } |
| 214 | + printNextSteps(targetDir, parsed.clean) |
| 215 | + } |
| 216 | + catch (error) { |
| 217 | + console.error('[create-icebreaker]', error?.message || error) |
| 218 | + process.exitCode = 1 |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +main() |
0 commit comments