Skip to content

Commit e0b0b47

Browse files
committed
chore: init create-icebreaker
1 parent 6f3701a commit e0b0b47

File tree

10 files changed

+378
-19
lines changed

10 files changed

+378
-19
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ monorepo-template is a production-oriented pnpm + Turbo monorepo template. It sh
2626
4. **Build and verify**: Run `pnpm build`, `pnpm test`, and `pnpm lint` to validate builds, tests, and linting.
2727
5. **Template cleanup (optional)**: Use `pnpm script:clean` to prune sample packages when personalising the template.
2828

29+
### Bootstrap shortcuts
30+
31+
- Zero-install cleanup on a fresh clone: `pnpm dlx @icebreakers/monorepo@latest clean --yes` (add `--include-private` to keep private packages in scope).
32+
- One-liner scaffold: `pnpm create icebreaker my-app` or `npm create icebreaker@latest my-app` clones the repo, strips `.git`, and runs the cleanup. Flags: `--no-clean` to keep samples, `--branch <name>` / `--repo <git-url>` to point at a different source.
33+
2934
## Repository Layout
3035

3136
```text

README.zh-CN.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ monorepo-template 面向实际项目,内置统一的构建、测试、发布
2626
4. **构建与验证**:依次运行 `pnpm build``pnpm test``pnpm lint` 完成本地构建、测试与代码检查。
2727
5. **模板清理(可选)**:执行 `pnpm script:clean` 清理示例包,为自定义项目腾出空间。
2828

29+
### 快捷初始化
30+
31+
- 零安装清理:`pnpm dlx @icebreakers/monorepo@latest clean --yes`,需要保留 private 包时追加 `--include-private`
32+
- 一键脚手架:`pnpm create icebreaker my-app``npm create icebreaker@latest my-app`,自动 clone 模板、移除 `.git` 并调用清理。常用参数:`--no-clean` 保留示例、`--branch <name>` / `--repo <git-url>` 指向其他来源。
33+
2934
## 仓库结构
3035

3136
```text
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# create-icebreaker
2+
3+
One-shot bootstrapper for the icebreaker monorepo template. It clones the template repo, removes `.git`, and optionally runs the built-in cleanup before you install dependencies.
4+
5+
## Usage
6+
7+
- `pnpm create icebreaker my-app`
8+
- `npm create icebreaker@latest my-app`
9+
10+
By default this:
11+
12+
- clones `sonofmagic/monorepo-template` (branch `main`) into `my-app`
13+
- strips the git history
14+
- runs `pnpm dlx @icebreakers/monorepo@latest clean --yes`
15+
16+
## Flags
17+
18+
- `--repo <git-url-or-owner/name>`: clone a different repo
19+
- `--branch <branch-or-tag>`: choose a branch or tag
20+
- `--no-clean`: skip running `monorepo clean`
21+
- `--include-private`: include private packages when cleaning
22+
- `--force`: overwrite a non-empty target directory
23+
- `--agent <pnpm|npm>`: force which tool to run the cleanup with
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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()
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "create-icebreaker",
3+
"type": "module",
4+
"version": "0.0.0",
5+
"description": "Bootstrap the icebreaker monorepo template via npm create/pnpm create with optional cleanup",
6+
"author": "ice breaker <1324318532@qq.com>",
7+
"license": "MIT",
8+
"repository": {
9+
"type": "git",
10+
"url": "git+https://github.com/sonofmagic/monorepo-template.git",
11+
"directory": "packages/create-icebreaker"
12+
},
13+
"bugs": {
14+
"url": "https://github.com/sonofmagic/monorepo-template/issues"
15+
},
16+
"keywords": [
17+
"monorepo",
18+
"create-app",
19+
"template",
20+
"scaffold"
21+
],
22+
"sideEffects": false,
23+
"bin": {
24+
"create-icebreaker": "bin/create-icebreaker.js"
25+
},
26+
"files": [
27+
"bin"
28+
],
29+
"engines": {
30+
"node": ">=18.0.0"
31+
},
32+
"scripts": {
33+
"lint": "eslint .",
34+
"test": "vitest run --passWithNoTests"
35+
},
36+
"publishConfig": {
37+
"access": "public"
38+
}
39+
}

packages/monorepo/src/cli/program.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AgenticTemplateFormat } from '../commands'
22
import type { CreateNewProjectOptions } from '../commands/create'
3-
import type { CliOpts } from '../types'
3+
import type { CleanCommandConfig, CliOpts } from '../types'
44
import process from 'node:process'
55
import input from '@inquirer/input'
66
import select from '@inquirer/select'
@@ -20,6 +20,12 @@ interface AiTemplateCommandOptions {
2020
tasks?: string
2121
}
2222

23+
interface CleanCommandOptions {
24+
yes?: boolean
25+
includePrivate?: boolean
26+
pinnedVersion?: string
27+
}
28+
2329
const cwd = process.cwd()
2430

2531
program.name(name).version(version)
@@ -55,10 +61,25 @@ program.command('sync').description('向 npmmirror 同步所有子包').action(a
5561
logger.success('sync npm mirror finished!')
5662
})
5763

58-
program.command('clean').description('清除选中的包').action(async () => {
59-
await cleanProjects(cwd)
60-
logger.success('clean projects finished!')
61-
})
64+
program.command('clean')
65+
.description('清除选中的包')
66+
.option('-y, --yes', '跳过交互直接清理(等价 autoConfirm)')
67+
.option('--include-private', '包含 private 包')
68+
.option('--pinned-version <version>', '覆盖写入的 @icebreakers/monorepo 版本')
69+
.action(async (opts: CleanCommandOptions) => {
70+
const overrides: Partial<CleanCommandConfig> = {}
71+
if (opts.yes) {
72+
overrides.autoConfirm = true
73+
}
74+
if (opts.includePrivate) {
75+
overrides.includePrivate = true
76+
}
77+
if (opts.pinnedVersion) {
78+
overrides.pinnedVersion = opts.pinnedVersion
79+
}
80+
await cleanProjects(cwd, overrides)
81+
logger.success('clean projects finished!')
82+
})
6283

6384
program.command('mirror').description('设置 VscodeBinaryMirror').action(async () => {
6485
await setVscodeBinaryMirror(cwd)

packages/monorepo/src/commands/clean.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
1+
import type { CleanCommandConfig } from '../types'
12
import checkbox from '@inquirer/checkbox'
23
import fs from 'fs-extra'
34
import path from 'pathe'
45
import set from 'set-value'
56
import { resolveCommandConfig } from '../core/config'
67
import { getWorkspaceData } from '../core/workspace'
78

9+
function mergeCleanConfig(base: CleanCommandConfig, overrides?: Partial<CleanCommandConfig>) {
10+
if (!overrides) {
11+
return base
12+
}
13+
const definedOverrides = Object.fromEntries(
14+
Object.entries(overrides).filter(([, value]) => value !== undefined),
15+
) as Partial<CleanCommandConfig>
16+
return {
17+
...base,
18+
...definedOverrides,
19+
}
20+
}
21+
822
/**
923
* 交互式清理被选中的包目录,同时重写根 package.json 中的 @icebreakers/monorepo 版本。
1024
*/
11-
export async function cleanProjects(cwd: string) {
12-
const cleanConfig = await resolveCommandConfig('clean', cwd)
25+
export async function cleanProjects(cwd: string, overrides?: Partial<CleanCommandConfig>) {
26+
const cleanConfig = mergeCleanConfig(await resolveCommandConfig('clean', cwd), overrides)
1327
const workspaceOptions = cleanConfig?.includePrivate ? { ignorePrivatePackage: false } : undefined
1428
const { packages, workspaceDir } = await getWorkspaceData(cwd, workspaceOptions)
1529

0 commit comments

Comments
 (0)