@vv0rkz/js-template 1.8.3 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,16 +52,33 @@ export default {
52
52
 
53
53
  // Релиз
54
54
  release: {
55
- requireDemo: true, // требовать GIF/PNG перед релизом
56
- demoDir: 'docs', // где искать демо
57
- demoFormats: ['gif', 'png'],
55
+ demo: {
56
+ enable: true, // требовать демо перед релизом
57
+ dir: 'docs', // где искать демо-файлы
58
+ formats: ['gif', 'png'], // допустимые форматы
59
+ style: 'click', // 'click' | 'side-by-side'
60
+ },
58
61
  },
59
62
 
60
63
  // Автообновление зависимостей
61
- depUpdater: false, // 'dependabot' | 'renovate' | false
64
+ // Короткая форма: 'dependabot' | 'renovate' | false
65
+ // Расширенная (только dependabot):
66
+ // { type: 'dependabot', ignoreMajor: true, autoMerge: { patch: true, minor: true } }
67
+ depUpdater: false,
68
+
69
+ // CI workflow (.github/workflows/ci.yml)
70
+ ci: {
71
+ enable: false, // включить генерацию ci.yml
72
+ checks: ['lint', 'test'], // имена job-ов = имена npm-скриптов
73
+ nodeVersion: '20',
74
+ },
62
75
 
63
- // Репозиторий JST для report-issue
64
- jstRepo: 'vv0rkz/js-template',
76
+ // Branch protection (через gh api)
77
+ branchProtection: {
78
+ enable: false,
79
+ requiredChecks: [], // должны совпадать с ci.checks
80
+ enforceAdmins: false,
81
+ },
65
82
  }
66
83
  ```
67
84
 
@@ -74,8 +91,10 @@ export default {
74
91
  | `release.*` | На следующем `jst release` |
75
92
  | `labels` | После `jst setup-labels` или `jst apply` |
76
93
  | `depUpdater` | После `jst setup-deps` или `jst apply` |
94
+ | `ci.*` | После `jst setup-ci` или `jst apply` |
95
+ | `branchProtection.*` | После `jst setup-branch-protection` (вручную) |
77
96
 
78
- Короткий путь — `jst apply` применит labels + deps за раз.
97
+ Короткий путь — `jst apply` применит labels + deps + ci за раз. Branch protection требует admin-прав и запускается отдельно.
79
98
 
80
99
  ## Команды
81
100
 
@@ -91,9 +110,11 @@ npx jst <команда> # напрямую
91
110
  |---|---|
92
111
  | `init` | Инициализация нового проекта |
93
112
  | `upgrade` | Обновить конфиги после `npm update` |
94
- | `apply` | Применить изменения из `jst.config.js` |
113
+ | `apply` | Применить изменения из `jst.config.js` (labels + deps + ci) |
95
114
  | `setup-labels` | Настроить GitHub labels |
96
- | `setup-deps` | Настроить dependabot/renovate |
115
+ | `setup-deps` | Настроить dependabot/renovate (+ auto-merge workflow) |
116
+ | `setup-ci` | Сгенерировать `.github/workflows/ci.yml` |
117
+ | `setup-branch-protection` | Настроить защиту главной ветки через `gh api` |
97
118
 
98
119
  ### Разработка
99
120
 
@@ -219,6 +240,33 @@ npm run _ push-release
219
240
  - [Changelogen](https://github.com/unjs/changelogen) — Генерация changelog
220
241
  - [GitHub CLI](https://cli.github.com/) — Управление issues
221
242
 
243
+ ## Migration Guide
244
+
245
+ ### v1.9.0 — `release.demo` (breaking change)
246
+
247
+ Настройки демо переехали из плоского `release.*` в объект `release.demo`:
248
+
249
+ ```js
250
+ // ❌ Было (до v1.9.0)
251
+ release: {
252
+ requireDemo: true,
253
+ demoDir: 'docs',
254
+ demoFormats: ['gif', 'png'],
255
+ }
256
+
257
+ // ✅ Стало
258
+ release: {
259
+ demo: {
260
+ enable: true,
261
+ dir: 'docs',
262
+ formats: ['gif', 'png'],
263
+ style: 'click', // новая опция: 'click' | 'side-by-side'
264
+ },
265
+ }
266
+ ```
267
+
268
+ Если в `jst.config.js` остались старые ключи, при запуске любой `jst`-команды появится предупреждение с инструкцией по миграции.
269
+
222
270
  ## Лицензия
223
271
 
224
272
  MIT © [vv0rkz](https://github.com/vv0rkz)
package/bin/cli.js CHANGED
@@ -101,6 +101,14 @@ const commands = {
101
101
  spawnSync('node', [join(toolsDir, 'setup-deps.js')], { stdio: 'inherit' })
102
102
  },
103
103
 
104
+ 'setup-ci': () => {
105
+ spawnSync('node', [join(toolsDir, 'setup-ci.js')], { stdio: 'inherit' })
106
+ },
107
+
108
+ 'setup-branch-protection': () => {
109
+ spawnSync('node', [join(toolsDir, 'setup-branch-protection.js')], { stdio: 'inherit' })
110
+ },
111
+
104
112
  upgrade: () => {
105
113
  spawnSync('node', [join(toolsDir, 'upgrade.js')], { stdio: 'inherit' })
106
114
  },
@@ -109,7 +117,10 @@ const commands = {
109
117
  console.log('⚙️ Применение jst.config.js...\n')
110
118
  spawnSync('node', [join(toolsDir, 'setup-labels.js')], { stdio: 'inherit' })
111
119
  spawnSync('node', [join(toolsDir, 'setup-deps.js')], { stdio: 'inherit' })
120
+ spawnSync('node', [join(toolsDir, 'setup-ci.js')], { stdio: 'inherit' })
112
121
  console.log('\n✅ Конфиг применён!')
122
+ console.log('💡 Branch protection не применяется автоматически (требует admin прав).')
123
+ console.log(' Запусти вручную: npm run _ setup-branch-protection')
113
124
  },
114
125
 
115
126
  'pr-list': () => {
@@ -175,6 +186,8 @@ if (commands[command]) {
175
186
  init-readme Создать стартовый README.md
176
187
  setup-labels Настроить GitHub labels
177
188
  setup-deps Настроить dependabot/renovate
189
+ setup-ci Сгенерировать .github/workflows/ci.yml
190
+ setup-branch-protection Настроить защиту главной ветки (gh api)
178
191
 
179
192
  🔧 РАЗРАБОТКА:
180
193
  update-readme Обновить README с версией
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vv0rkz/js-template",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "description": "Reusable setup for JS projects with husky, changelog, gh tools",
5
5
  "type": "module",
6
6
  "bin": {
@@ -121,12 +121,55 @@ export default {
121
121
 
122
122
  /**
123
123
  * Автообновление зависимостей
124
- * @type {'dependabot' | 'renovate' | false}
125
124
  *
126
- * 'dependabot' — создаст .github/dependabot.yml
127
- * 'renovate' — создаст renovate.json
128
- * false отключено
125
+ * Короткая форма:
126
+ * depUpdater: 'dependabot' — создаст .github/dependabot.yml
127
+ * depUpdater: 'renovate' создаст renovate.json
128
+ * depUpdater: false — отключено
129
+ *
130
+ * Расширенная форма (только dependabot):
131
+ * depUpdater: {
132
+ * type: 'dependabot',
133
+ * ignoreMajor: true, // не открывать major PR-ы
134
+ * ignore: { major: true, minor: false }, // или более гранулярно
135
+ * autoMerge: { patch: true, minor: true, major: false },
136
+ * }
137
+ *
138
+ * При `autoMerge` jst дополнительно создаёт
139
+ * .github/workflows/dependabot-auto-merge.yml
129
140
  */
130
141
  depUpdater: false,
131
142
 
143
+ /**
144
+ * Continuous Integration (GitHub Actions)
145
+ *
146
+ * При `enable: true` команда `jst setup-ci` (и `jst apply`) создаёт
147
+ * .github/workflows/ci.yml — workflow с по одной job на каждый check.
148
+ * Имя job совпадает с именем npm-скрипта, который оно запускает.
149
+ *
150
+ * ci.yml: jobs.lint → runs `npm run lint`
151
+ * jobs.test → runs `npm run test`
152
+ */
153
+ ci: {
154
+ enable: false,
155
+ checks: ['lint', 'test'],
156
+ nodeVersion: '20',
157
+ },
158
+
159
+ /**
160
+ * Branch protection (через `gh api`)
161
+ *
162
+ * Команда `jst setup-branch-protection` настраивает защиту главной ветки
163
+ * через GitHub API. Не запускается автоматически в `jst apply` —
164
+ * требует admin прав на репозитории.
165
+ *
166
+ * requiredChecks — список обязательных status checks (должны совпадать
167
+ * с именами job-ов из ci.checks).
168
+ */
169
+ branchProtection: {
170
+ enable: false,
171
+ requiredChecks: [],
172
+ enforceAdmins: false,
173
+ },
174
+
132
175
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync } from 'fs'
3
- import { execSync } from 'child_process'
4
3
  import { loadConfig } from './config.js'
4
+ import { predictNextVersion } from './version-utils.js'
5
5
 
6
6
  const config = await loadConfig()
7
7
 
@@ -11,16 +11,7 @@ if (!config.release.demo.enable) {
11
11
  }
12
12
 
13
13
  const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
14
- const [major, minor, patch] = packageJson.version.split('.').map(Number)
15
-
16
- const commitMessages = execSync('git log --oneline -10', { encoding: 'utf8' })
17
-
18
- let nextVersion
19
- if (commitMessages.includes('feat:')) {
20
- nextVersion = `v${major}.${minor + 1}.0`
21
- } else {
22
- nextVersion = `v${major}.${minor}.${patch + 1}`
23
- }
14
+ const nextVersion = predictNextVersion(packageJson.version)
24
15
 
25
16
  console.log(`📦 Предполагаемая следующая версия: ${nextVersion}`)
26
17
 
@@ -35,6 +35,28 @@ const DEFAULTS = {
35
35
  },
36
36
  },
37
37
  depUpdater: false,
38
+ ci: {
39
+ enable: false,
40
+ checks: ['lint', 'test'],
41
+ nodeVersion: '20',
42
+ },
43
+ branchProtection: {
44
+ enable: false,
45
+ requiredChecks: [],
46
+ enforceAdmins: false,
47
+ },
48
+ }
49
+
50
+ /**
51
+ * Normalizes `depUpdater` to an object form.
52
+ * false → { type: false }
53
+ * 'dependabot' → { type: 'dependabot' }
54
+ * { type, ...opts } → unchanged
55
+ */
56
+ export function normalizeDepUpdater(depUpdater) {
57
+ if (!depUpdater) return { type: false }
58
+ if (typeof depUpdater === 'string') return { type: depUpdater }
59
+ return { type: false, ...depUpdater }
38
60
  }
39
61
 
40
62
  // --- Branch pattern template engine ---
@@ -100,6 +122,35 @@ function deepMerge(target, source) {
100
122
  return result
101
123
  }
102
124
 
125
+ /**
126
+ * Checks for deprecated config keys and prints migration warnings.
127
+ * Old flat release.* keys were moved to release.demo.* in v1.9.0.
128
+ */
129
+ function warnDeprecatedKeys(userConfig) {
130
+ const deprecated = [
131
+ { old: 'release.requireDemo', newKey: 'release.demo.enable' },
132
+ { old: 'release.demoDir', newKey: 'release.demo.dir' },
133
+ { old: 'release.demoFormats', newKey: 'release.demo.formats' },
134
+ ]
135
+
136
+ const found = deprecated.filter(({ old }) => {
137
+ const [top, key] = old.split('.')
138
+ return userConfig[top]?.[key] !== undefined
139
+ })
140
+
141
+ if (found.length === 0) return
142
+
143
+ console.warn('\n⚠️ jst.config.js: устаревшие настройки (обнови конфиг)\n')
144
+ found.forEach(({ old, newKey }) => {
145
+ console.warn(` ${old} → ${newKey}`)
146
+ })
147
+ console.warn('\n Было:')
148
+ console.warn(' release: { requireDemo, demoDir, demoFormats }')
149
+ console.warn('\n Стало (начиная с v1.9.0):')
150
+ console.warn(' release: { demo: { enable, dir, formats, style } }')
151
+ console.warn('\n Запусти: npm run _ upgrade — чтобы получить актуальный конфиг\n')
152
+ }
153
+
103
154
  /**
104
155
  * Loads config from jst.config.js (preferred) or jst.config.json (fallback)
105
156
  * Deep-merges user config with defaults
@@ -124,5 +175,7 @@ export async function loadConfig() {
124
175
  }
125
176
  }
126
177
 
178
+ warnDeprecatedKeys(userConfig)
179
+
127
180
  return deepMerge(DEFAULTS, userConfig)
128
181
  }
@@ -5,8 +5,13 @@ import { loadConfig } from './config.js'
5
5
  const config = await loadConfig()
6
6
  const { closeKeyword, requireIssue } = config.commits
7
7
 
8
- const commitMsg = execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim()
9
- const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim()
8
+ function safeExec(cmd) {
9
+ try {
10
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
11
+ } catch {
12
+ return ''
13
+ }
14
+ }
10
15
 
11
16
  let repoUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim()
12
17
  if (repoUrl.startsWith('git@github.com:')) {
@@ -15,26 +20,56 @@ if (repoUrl.startsWith('git@github.com:')) {
15
20
  repoUrl = repoUrl.replace(/\.git$/, '')
16
21
  }
17
22
 
18
- execSync('git push origin HEAD', { stdio: 'inherit' })
23
+ // Capture upstream BEFORE push so we can scan everything that lands on the
24
+ // remote in this push — including older commits whose pushes were previously
25
+ // rejected by pre-push (fixes #10).
26
+ const beforeUpstream = safeExec('git rev-parse @{u}')
27
+
28
+ try {
29
+ execSync('git push -u origin HEAD', { stdio: 'inherit' })
30
+ } catch {
31
+ // pre-push hook may have rejected the push; nothing to close yet.
32
+ process.exit(1)
33
+ }
34
+
35
+ // On successful push, scan every commit between the previously known remote
36
+ // HEAD and the new HEAD for close-keywords. If we had no upstream before,
37
+ // fall back to the last 50 commits.
38
+ const range = beforeUpstream ? `${beforeUpstream}..HEAD` : '-50'
39
+ const log = safeExec(`git log ${range} --format=%H%x09%s`)
40
+
41
+ if (!log) process.exit(0)
19
42
 
20
43
  const escClose = closeKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
21
44
  const typesGroup = requireIssue.join('|')
22
45
  const closePattern = new RegExp(`^(${typesGroup})(\\(.+\\))?: ${escClose} #(\\d+)`)
23
- const match = commitMsg.match(closePattern)
24
46
 
25
- if (match) {
47
+ const seen = new Set()
48
+
49
+ for (const line of log.split('\n').filter(Boolean)) {
50
+ const tabIndex = line.indexOf('\t')
51
+ if (tabIndex === -1) continue
52
+ const hash = line.slice(0, tabIndex)
53
+ const subject = line.slice(tabIndex + 1)
54
+
55
+ const match = subject.match(closePattern)
56
+ if (!match) continue
57
+
26
58
  const issueNumber = match[3]
27
- const commitLink = `${repoUrl}/commit/${commitHash}`
28
- const firstLine = commitMsg.split('\n')[0]
59
+ if (seen.has(issueNumber)) continue
60
+ seen.add(issueNumber)
61
+
62
+ const shortHash = hash.slice(0, 7)
63
+ const commitLink = `${repoUrl}/commit/${hash}`
29
64
 
30
- console.log(`🔧 Закрываю issue #${issueNumber}...`)
65
+ console.log(`🔧 Закрываю issue #${issueNumber} (коммит ${shortHash})...`)
31
66
 
32
67
  try {
33
68
  execSync(
34
- `gh issue close "${issueNumber}" --comment "✅ Завершено в коммите: [${commitHash}](${commitLink}) - ${firstLine}"`,
69
+ `gh issue close "${issueNumber}" --comment "✅ Завершено в коммите: [${shortHash}](${commitLink}) - ${subject}"`,
35
70
  { stdio: 'inherit' },
36
71
  )
37
72
  } catch {
38
- console.log(`⚠️ Не удалось закрыть issue #${issueNumber}`)
73
+ console.log(`⚠️ Не удалось закрыть issue #${issueNumber} (возможно уже закрыт)`)
39
74
  }
40
75
  }
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { execSync, spawnSync } from 'child_process'
2
+ import { spawnSync } from 'child_process'
3
3
  import { existsSync, readFileSync } from 'fs'
4
4
  import { platform } from 'os'
5
5
  import { dirname, join } from 'path'
6
6
  import { fileURLToPath } from 'url'
7
7
  import { loadConfig } from './config.js'
8
+ import { getCommitsSinceLastTag, getLastTag, predictNextVersion } from './version-utils.js'
8
9
 
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url))
10
11
  const isWin = platform() === 'win32'
@@ -23,12 +24,7 @@ console.log(`📦 Текущая версия: ${currentVersion}`)
23
24
 
24
25
  // 2. Demo check (configurable)
25
26
  if (config.release.demo.enable) {
26
- const [major, minor, patch] = currentVersion.split('.').map(Number)
27
- const recentCommits = execSync('git log --oneline -10', { encoding: 'utf8' })
28
- const nextVersion = recentCommits.includes('feat:')
29
- ? `v${major}.${minor + 1}.0`
30
- : `v${major}.${minor}.${patch + 1}`
31
-
27
+ const nextVersion = predictNextVersion(currentVersion)
32
28
  console.log(`📦 Предполагаемая следующая версия: ${nextVersion}`)
33
29
 
34
30
  const { dir: demoDir, formats: demoFormats } = config.release.demo
@@ -58,19 +54,11 @@ if (currentVersion.startsWith('0.')) {
58
54
  }
59
55
 
60
56
  // 4. Commit analysis
61
- let lastTag = ''
62
- try {
63
- lastTag = execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim()
64
- console.log(`📌 Последний тег: ${lastTag}`)
65
- } catch {
66
- console.log('📌 Теги не найдены (первый релиз)')
67
- }
68
-
69
- const commitLog = lastTag
70
- ? execSync(`git log ${lastTag}..HEAD --format=%s`, { encoding: 'utf8' })
71
- : execSync('git log --format=%s -10', { encoding: 'utf8' })
57
+ const lastTag = getLastTag()
58
+ if (lastTag) console.log(`📌 Последний тег: ${lastTag}`)
59
+ else console.log('📌 Теги не найдены (первый релиз)')
72
60
 
73
- const commits = commitLog.split('\n').filter(Boolean)
61
+ const commits = getCommitsSinceLastTag(lastTag)
74
62
  const countByType = (prefix) => commits.filter((c) => c.startsWith(`${prefix}:`)).length
75
63
 
76
64
  const featCount = countByType('feat')
@@ -6,25 +6,41 @@ const jstRepo = 'vv0rkz/js-template'
6
6
  const isWin = platform() === 'win32'
7
7
  const ghCmd = isWin ? 'gh.exe' : 'gh'
8
8
 
9
- const title = process.argv.slice(2).join(' ')
9
+ const rawArgs = process.argv.slice(2)
10
10
 
11
11
  console.log(`📝 Создаю issue в ${jstRepo}...`)
12
12
 
13
- if (!title) {
14
- const result = spawnSync(ghCmd, ['issue', 'create', '--repo', jstRepo], { stdio: 'inherit' })
15
- process.exit(result.status || 0)
13
+ // If the caller passed any flag (anything starting with `-`), we assume a
14
+ // non-interactive invocation and forward every argument verbatim to
15
+ // `gh issue create`. This enables script/CI usage like:
16
+ // jst report-issue --title "..." --body-file body.md --label bug
17
+ //
18
+ // Otherwise we keep the historical shorthand and join the positional args
19
+ // into the title:
20
+ // jst report-issue "Quick bug description"
21
+ const hasFlags = rawArgs.some((a) => a.startsWith('-'))
22
+
23
+ let ghArgs
24
+ if (rawArgs.length === 0) {
25
+ ghArgs = ['issue', 'create', '--repo', jstRepo]
26
+ } else if (hasFlags) {
27
+ ghArgs = ['issue', 'create', '--repo', jstRepo, ...rawArgs]
28
+ } else {
29
+ ghArgs = ['issue', 'create', '--repo', jstRepo, '--title', rawArgs.join(' ')]
16
30
  }
17
31
 
18
- const result = spawnSync(ghCmd, ['issue', 'create', '--repo', jstRepo, '--title', title], {
19
- stdio: 'inherit',
20
- })
32
+ const result = spawnSync(ghCmd, ghArgs, { stdio: 'inherit' })
21
33
 
22
34
  if (result.status === 0) {
23
35
  console.log('\n✅ Issue создан!')
24
- } else {
25
- console.error('❌ Ошибка создания issue')
26
- console.log('\n💡 Убедись что:')
27
- console.log(' 1. Установлен GitHub CLI: gh --version')
28
- console.log(' 2. Выполнена авторизация: gh auth login')
29
- process.exit(1)
36
+ process.exit(0)
30
37
  }
38
+
39
+ console.error('❌ Ошибка создания issue')
40
+ console.log('\n💡 Убедись что:')
41
+ console.log(' 1. Установлен GitHub CLI: gh --version')
42
+ console.log(' 2. Выполнена авторизация: gh auth login')
43
+ console.log(' 3. Для non-interactive вызова используй флаги:')
44
+ console.log(' jst report-issue --title "..." --body "..."')
45
+ console.log(' jst report-issue --title "..." --body-file body.md')
46
+ process.exit(result.status || 1)
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import { execSync, spawnSync } from 'child_process'
3
+ import { platform } from 'os'
4
+ import { loadConfig } from './config.js'
5
+
6
+ const config = await loadConfig()
7
+ const bp = config.branchProtection
8
+
9
+ if (!bp || !bp.enable) {
10
+ console.log('⏭️ Branch protection отключена (branchProtection.enable = false)')
11
+ console.log('💡 Включи в jst.config.js: branchProtection: { enable: true, requiredChecks: [...] }')
12
+ process.exit(0)
13
+ }
14
+
15
+ const requiredChecks = Array.isArray(bp.requiredChecks) ? bp.requiredChecks.filter(Boolean) : []
16
+ const enforceAdmins = !!bp.enforceAdmins
17
+ const mainBranch = (config.branch && config.branch.main) || 'main'
18
+
19
+ const isWin = platform() === 'win32'
20
+ const ghCmd = isWin ? 'gh.exe' : 'gh'
21
+
22
+ console.log(`🔒 Настройка branch protection для ветки "${mainBranch}"...\n`)
23
+
24
+ try {
25
+ execSync(`${ghCmd} auth status`, { stdio: 'ignore' })
26
+ } catch {
27
+ console.error('❌ GitHub CLI не установлен или не авторизован')
28
+ console.log('💡 Установи: https://cli.github.com/')
29
+ console.log(' И выполни: gh auth login')
30
+ process.exit(1)
31
+ }
32
+
33
+ let repoSlug = ''
34
+ try {
35
+ const remote = execSync('git remote get-url origin', { encoding: 'utf8' }).trim()
36
+ const match = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/)
37
+ if (match) repoSlug = match[1]
38
+ } catch {
39
+ // ignore
40
+ }
41
+
42
+ if (!repoSlug) {
43
+ console.error('❌ Не удалось определить owner/repo из git remote origin')
44
+ process.exit(1)
45
+ }
46
+
47
+ const payload = {
48
+ required_status_checks:
49
+ requiredChecks.length > 0 ? { strict: true, contexts: requiredChecks } : null,
50
+ enforce_admins: enforceAdmins,
51
+ required_pull_request_reviews: null,
52
+ restrictions: null,
53
+ }
54
+
55
+ console.log(`📍 Repo: ${repoSlug}`)
56
+ console.log(`📍 Required checks: ${requiredChecks.length ? requiredChecks.join(', ') : '(none)'}`)
57
+ console.log(`📍 Enforce admins: ${enforceAdmins}`)
58
+ console.log()
59
+
60
+ const result = spawnSync(
61
+ ghCmd,
62
+ ['api', '-X', 'PUT', `repos/${repoSlug}/branches/${mainBranch}/protection`, '--input', '-'],
63
+ {
64
+ input: JSON.stringify(payload),
65
+ stdio: ['pipe', 'inherit', 'inherit'],
66
+ encoding: 'utf8',
67
+ },
68
+ )
69
+
70
+ if (result.status === 0) {
71
+ console.log('\n✅ Branch protection настроена!')
72
+ } else {
73
+ console.error('\n❌ Не удалось настроить branch protection')
74
+ console.log('💡 Возможные причины:')
75
+ console.log(' • Нет прав admin на репозиторий')
76
+ console.log(' • Репозиторий приватный на бесплатном плане (нужен Pro/Team/Enterprise)')
77
+ console.log(' • Указанные required checks ещё не запускались на этой ветке')
78
+ process.exit(result.status || 1)
79
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, writeFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { loadConfig } from './config.js'
5
+
6
+ const config = await loadConfig()
7
+ const ci = config.ci
8
+
9
+ if (!ci || !ci.enable) {
10
+ console.log('⏭️ CI отключён (ci.enable = false)')
11
+ process.exit(0)
12
+ }
13
+
14
+ const checks = Array.isArray(ci.checks) ? ci.checks.filter(Boolean) : []
15
+ if (checks.length === 0) {
16
+ console.log('⚠️ ci.enable = true, но ci.checks пуст — workflow не создан')
17
+ process.exit(0)
18
+ }
19
+
20
+ const nodeVersion = ci.nodeVersion || '20'
21
+ const mainBranch = (config.branch && config.branch.main) || 'main'
22
+
23
+ const workflowsDir = join(process.cwd(), '.github', 'workflows')
24
+ if (!existsSync(workflowsDir)) mkdirSync(workflowsDir, { recursive: true })
25
+
26
+ const filePath = join(workflowsDir, 'ci.yml')
27
+ if (existsSync(filePath)) {
28
+ console.log('⏭️ .github/workflows/ci.yml уже существует')
29
+ process.exit(0)
30
+ }
31
+
32
+ const jobs = checks
33
+ .map(
34
+ (check) => ` ${check}:
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+ - uses: actions/setup-node@v4
39
+ with:
40
+ node-version: '${nodeVersion}'
41
+ cache: npm
42
+ - run: npm ci
43
+ - run: npm run ${check}`,
44
+ )
45
+ .join('\n\n')
46
+
47
+ const yaml = `name: CI
48
+
49
+ on:
50
+ push:
51
+ branches: [${mainBranch}]
52
+ pull_request:
53
+ branches: [${mainBranch}]
54
+
55
+ jobs:
56
+ ${jobs}
57
+ `
58
+
59
+ writeFileSync(filePath, yaml)
60
+ console.log('✅ Создан .github/workflows/ci.yml')
61
+ console.log(` ↳ checks: ${checks.join(', ')}`)
62
+ console.log(` ↳ node: ${nodeVersion}`)
63
+ console.log(` ↳ branch: ${mainBranch}`)
@@ -1,43 +1,139 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, writeFileSync } from 'fs'
3
3
  import { join } from 'path'
4
- import { loadConfig } from './config.js'
4
+ import { loadConfig, normalizeDepUpdater } from './config.js'
5
5
 
6
6
  const config = await loadConfig()
7
- const { depUpdater } = config
7
+ const dep = normalizeDepUpdater(config.depUpdater)
8
8
 
9
- if (!depUpdater) {
9
+ if (!dep.type) {
10
10
  console.log('⏭️ Автообновление зависимостей отключено (depUpdater = false)')
11
11
  process.exit(0)
12
12
  }
13
13
 
14
- if (depUpdater === 'dependabot') {
14
+ if (dep.type === 'dependabot') {
15
+ writeDependabotConfig(dep)
16
+ if (dep.autoMerge) {
17
+ // shorthand: `autoMerge: true` → enable patch + minor auto-merge
18
+ const am = dep.autoMerge === true ? { patch: true, minor: true } : dep.autoMerge
19
+ writeDependabotAutoMergeWorkflow(am)
20
+ }
21
+ } else if (dep.type === 'renovate') {
22
+ writeRenovateConfig()
23
+ } else {
24
+ console.log(`⚠️ Неизвестный depUpdater.type: "${dep.type}"`)
25
+ console.log('💡 Допустимые значения: "dependabot", "renovate", false')
26
+ process.exit(1)
27
+ }
28
+
29
+ // ---- helpers ------------------------------------------------------------
30
+
31
+ function buildIgnoreList(dep) {
32
+ // Granular `ignore: { major, minor, patch }` takes precedence over
33
+ // the simple `ignoreMajor` boolean.
34
+ const types = []
35
+ if (dep.ignore && typeof dep.ignore === 'object') {
36
+ if (dep.ignore.major) types.push('version-update:semver-major')
37
+ if (dep.ignore.minor) types.push('version-update:semver-minor')
38
+ if (dep.ignore.patch) types.push('version-update:semver-patch')
39
+ } else if (dep.ignoreMajor) {
40
+ types.push('version-update:semver-major')
41
+ }
42
+ return types
43
+ }
44
+
45
+ function writeDependabotConfig(dep) {
15
46
  const dir = join(process.cwd(), '.github')
16
47
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
17
48
 
18
49
  const filePath = join(dir, 'dependabot.yml')
19
50
  if (existsSync(filePath)) {
20
51
  console.log('⏭️ .github/dependabot.yml уже существует')
21
- process.exit(0)
52
+ return
22
53
  }
23
54
 
24
- writeFileSync(
25
- filePath,
26
- `version: 2
27
- updates:
28
- - package-ecosystem: "npm"
29
- directory: "/"
30
- schedule:
31
- interval: "weekly"
32
- open-pull-requests-limit: 10
33
- `,
34
- )
55
+ const ignoreTypes = buildIgnoreList(dep)
56
+ const ignoreBlock = ignoreTypes.length
57
+ ? ` ignore:\n - dependency-name: "*"\n update-types: [${ignoreTypes
58
+ .map((t) => `"${t}"`)
59
+ .join(', ')}]\n`
60
+ : ''
61
+
62
+ const yaml =
63
+ `version: 2\n` +
64
+ `updates:\n` +
65
+ ` - package-ecosystem: "npm"\n` +
66
+ ` directory: "/"\n` +
67
+ ` schedule:\n` +
68
+ ` interval: "weekly"\n` +
69
+ ` open-pull-requests-limit: 10\n` +
70
+ ignoreBlock
71
+
72
+ writeFileSync(filePath, yaml)
35
73
  console.log('✅ Создан .github/dependabot.yml')
36
- } else if (depUpdater === 'renovate') {
74
+ if (ignoreTypes.length) {
75
+ console.log(` ↳ ignore update-types: ${ignoreTypes.join(', ')}`)
76
+ }
77
+ }
78
+
79
+ function writeDependabotAutoMergeWorkflow(autoMerge) {
80
+ const dir = join(process.cwd(), '.github', 'workflows')
81
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
82
+
83
+ const filePath = join(dir, 'dependabot-auto-merge.yml')
84
+ if (existsSync(filePath)) {
85
+ console.log('⏭️ .github/workflows/dependabot-auto-merge.yml уже существует')
86
+ return
87
+ }
88
+
89
+ const conditions = []
90
+ if (autoMerge.patch) conditions.push("steps.meta.outputs.update-type == 'version-update:semver-patch'")
91
+ if (autoMerge.minor) conditions.push("steps.meta.outputs.update-type == 'version-update:semver-minor'")
92
+ if (autoMerge.major) conditions.push("steps.meta.outputs.update-type == 'version-update:semver-major'")
93
+
94
+ if (conditions.length === 0) {
95
+ console.log('⏭️ depUpdater.autoMerge не содержит ни одного true — auto-merge workflow не создан')
96
+ return
97
+ }
98
+
99
+ const ifClause = conditions.join(' || ')
100
+
101
+ const yaml = `name: Dependabot auto-merge
102
+
103
+ on: pull_request
104
+
105
+ permissions:
106
+ contents: write
107
+ pull-requests: write
108
+
109
+ jobs:
110
+ dependabot:
111
+ runs-on: ubuntu-latest
112
+ if: github.actor == 'dependabot[bot]'
113
+ steps:
114
+ - name: Fetch metadata
115
+ id: meta
116
+ uses: dependabot/fetch-metadata@v2
117
+ with:
118
+ github-token: "\${{ secrets.GITHUB_TOKEN }}"
119
+
120
+ - name: Enable auto-merge
121
+ if: ${ifClause}
122
+ run: gh pr merge --auto --squash "$PR_URL"
123
+ env:
124
+ PR_URL: \${{ github.event.pull_request.html_url }}
125
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
126
+ `
127
+
128
+ writeFileSync(filePath, yaml)
129
+ console.log('✅ Создан .github/workflows/dependabot-auto-merge.yml')
130
+ }
131
+
132
+ function writeRenovateConfig() {
37
133
  const filePath = join(process.cwd(), 'renovate.json')
38
134
  if (existsSync(filePath)) {
39
135
  console.log('⏭️ renovate.json уже существует')
40
- process.exit(0)
136
+ return
41
137
  }
42
138
 
43
139
  writeFileSync(
@@ -52,8 +148,4 @@ updates:
52
148
  ) + '\n',
53
149
  )
54
150
  console.log('✅ Создан renovate.json')
55
- } else {
56
- console.log(`⚠️ Неизвестный depUpdater: "${depUpdater}"`)
57
- console.log('💡 Допустимые значения: "dependabot", "renovate", false')
58
- process.exit(1)
59
151
  }
@@ -0,0 +1,40 @@
1
+ import { execSync } from 'child_process'
2
+
3
+ /**
4
+ * Returns the last tag (e.g. "v1.8.4") or '' if no tags exist.
5
+ */
6
+ export function getLastTag() {
7
+ try {
8
+ return execSync('git describe --tags --abbrev=0', {
9
+ encoding: 'utf8',
10
+ stdio: ['pipe', 'pipe', 'ignore'],
11
+ }).trim()
12
+ } catch {
13
+ return ''
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Returns commit subjects since the last tag (or last 10 commits if no tag).
19
+ */
20
+ export function getCommitsSinceLastTag(lastTag = getLastTag()) {
21
+ const log = lastTag
22
+ ? execSync(`git log ${lastTag}..HEAD --format=%s`, { encoding: 'utf8' })
23
+ : execSync('git log --format=%s -10', { encoding: 'utf8' })
24
+ return log.split('\n').filter(Boolean)
25
+ }
26
+
27
+ /**
28
+ * Predicts the next semver version string ("vX.Y.Z") based on commits since the
29
+ * last tag. If any feat: commit is present → minor bump; otherwise patch bump.
30
+ *
31
+ * Uses commits since the last tag (not last 10 commits) so the prediction stays
32
+ * correct even after many docs:/refactor: commits land on top of older feat:s.
33
+ */
34
+ export function predictNextVersion(currentVersion) {
35
+ const [major, minor, patch] = currentVersion.split('.').map(Number)
36
+ const commits = getCommitsSinceLastTag()
37
+ const hasFeat = commits.some((c) => /^feat(\(.+\))?!?:/.test(c))
38
+
39
+ return hasFeat ? `v${major}.${minor + 1}.0` : `v${major}.${minor}.${patch + 1}`
40
+ }