@vv0rkz/js-template 1.8.4 → 1.10.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
@@ -58,10 +58,33 @@ export default {
58
58
  formats: ['gif', 'png'], // допустимые форматы
59
59
  style: 'click', // 'click' | 'side-by-side'
60
60
  },
61
+ // Pre-run хук: перед каждой jst-командой сравнивает теги ↔ Releases ↔
62
+ // демо ↔ README и выводит предупреждения (не блокирует команду).
63
+ audit: {
64
+ enable: true,
65
+ warnOnly: true,
66
+ },
61
67
  },
62
68
 
63
69
  // Автообновление зависимостей
64
- depUpdater: false, // 'dependabot' | 'renovate' | false
70
+ // Короткая форма: 'dependabot' | 'renovate' | false
71
+ // Расширенная (только dependabot):
72
+ // { type: 'dependabot', ignoreMajor: true, autoMerge: { patch: true, minor: true } }
73
+ depUpdater: false,
74
+
75
+ // CI workflow (.github/workflows/ci.yml)
76
+ ci: {
77
+ enable: false, // включить генерацию ci.yml
78
+ checks: ['lint', 'test'], // имена job-ов = имена npm-скриптов
79
+ nodeVersion: '20',
80
+ },
81
+
82
+ // Branch protection (через gh api)
83
+ branchProtection: {
84
+ enable: false,
85
+ requiredChecks: [], // должны совпадать с ci.checks
86
+ enforceAdmins: false,
87
+ },
65
88
  }
66
89
  ```
67
90
 
@@ -74,8 +97,10 @@ export default {
74
97
  | `release.*` | На следующем `jst release` |
75
98
  | `labels` | После `jst setup-labels` или `jst apply` |
76
99
  | `depUpdater` | После `jst setup-deps` или `jst apply` |
100
+ | `ci.*` | После `jst setup-ci` или `jst apply` |
101
+ | `branchProtection.*` | После `jst setup-branch-protection` (вручную) |
77
102
 
78
- Короткий путь — `jst apply` применит labels + deps за раз.
103
+ Короткий путь — `jst apply` применит labels + deps + ci за раз. Branch protection требует admin-прав и запускается отдельно.
79
104
 
80
105
  ## Команды
81
106
 
@@ -91,9 +116,15 @@ npx jst <команда> # напрямую
91
116
  |---|---|
92
117
  | `init` | Инициализация нового проекта |
93
118
  | `upgrade` | Обновить конфиги после `npm update` |
94
- | `apply` | Применить изменения из `jst.config.js` |
119
+ | `apply` | Применить изменения из `jst.config.js` (labels + deps + ci) |
95
120
  | `setup-labels` | Настроить GitHub labels |
96
- | `setup-deps` | Настроить dependabot/renovate |
121
+ | `setup-deps` | Настроить dependabot/renovate (+ auto-merge workflow) |
122
+ | `setup-ci` | Сгенерировать `.github/workflows/ci.yml` |
123
+ | `setup-branch-protection` | Настроить защиту главной ветки через `gh api` |
124
+ | `audit` | Проверить теги/Releases/демо/README на пробелы |
125
+ | `fix-releases` | Создать GitHub Release для тегов без Release |
126
+
127
+ > Audit запускается автоматически перед каждой `jst`-командой (кроме `init`, `upgrade`, `audit`, `fix-releases`). Отключить разово — флагом `--no-audit`: `jst --no-audit create-task "..."`. Полностью — в `jst.config.js`: `release.audit.enable = false`.
97
128
 
98
129
  ### Разработка
99
130
 
package/bin/cli.js CHANGED
@@ -7,6 +7,13 @@ import { fileURLToPath } from 'url'
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url))
8
8
  const toolsDir = join(__dirname, '../tools-gh')
9
9
 
10
+ // Strip the global `--no-audit` flag from argv before anyone else looks at it,
11
+ // so downstream command handlers (some of which read process.argv directly)
12
+ // don't see it as a positional argument.
13
+ const noAuditIndex = process.argv.indexOf('--no-audit')
14
+ const noAudit = noAuditIndex !== -1
15
+ if (noAudit) process.argv.splice(noAuditIndex, 1)
16
+
10
17
  const args = process.argv.slice(2)
11
18
  const command = args[0]
12
19
  const commandArgs = args.slice(1)
@@ -15,6 +22,16 @@ const isWin = platform() === 'win32'
15
22
  const npxCmd = isWin ? 'npx.cmd' : 'npx'
16
23
  const ghCmd = isWin ? 'gh.exe' : 'gh'
17
24
 
25
+ // Commands that should NOT trigger the release audit pre-run hook.
26
+ // `init` runs before tags exist; `audit` / `fix-releases` ARE the audit
27
+ // itself; `upgrade` runs right after `npm update` and may pre-date tags.
28
+ const SKIP_AUDIT = new Set(['audit', 'fix-releases', 'init', 'upgrade', undefined])
29
+
30
+ function maybeRunAudit() {
31
+ if (noAudit || SKIP_AUDIT.has(command)) return
32
+ spawnSync('node', [join(toolsDir, 'release-audit.js'), '--quiet-if-clean'], { stdio: 'inherit' })
33
+ }
34
+
18
35
  const commands = {
19
36
  init: () => {
20
37
  const initScript = join(__dirname, 'init.js')
@@ -101,6 +118,22 @@ const commands = {
101
118
  spawnSync('node', [join(toolsDir, 'setup-deps.js')], { stdio: 'inherit' })
102
119
  },
103
120
 
121
+ 'setup-ci': () => {
122
+ spawnSync('node', [join(toolsDir, 'setup-ci.js')], { stdio: 'inherit' })
123
+ },
124
+
125
+ 'setup-branch-protection': () => {
126
+ spawnSync('node', [join(toolsDir, 'setup-branch-protection.js')], { stdio: 'inherit' })
127
+ },
128
+
129
+ audit: () => {
130
+ spawnSync('node', [join(toolsDir, 'release-audit.js')], { stdio: 'inherit' })
131
+ },
132
+
133
+ 'fix-releases': () => {
134
+ spawnSync('node', [join(toolsDir, 'fix-releases.js')], { stdio: 'inherit' })
135
+ },
136
+
104
137
  upgrade: () => {
105
138
  spawnSync('node', [join(toolsDir, 'upgrade.js')], { stdio: 'inherit' })
106
139
  },
@@ -109,7 +142,10 @@ const commands = {
109
142
  console.log('⚙️ Применение jst.config.js...\n')
110
143
  spawnSync('node', [join(toolsDir, 'setup-labels.js')], { stdio: 'inherit' })
111
144
  spawnSync('node', [join(toolsDir, 'setup-deps.js')], { stdio: 'inherit' })
145
+ spawnSync('node', [join(toolsDir, 'setup-ci.js')], { stdio: 'inherit' })
112
146
  console.log('\n✅ Конфиг применён!')
147
+ console.log('💡 Branch protection не применяется автоматически (требует admin прав).')
148
+ console.log(' Запусти вручную: npm run _ setup-branch-protection')
113
149
  },
114
150
 
115
151
  'pr-list': () => {
@@ -161,6 +197,7 @@ const commands = {
161
197
  }
162
198
 
163
199
  if (commands[command]) {
200
+ maybeRunAudit()
164
201
  commands[command]()
165
202
  } else {
166
203
  console.log(`
@@ -175,6 +212,10 @@ if (commands[command]) {
175
212
  init-readme Создать стартовый README.md
176
213
  setup-labels Настроить GitHub labels
177
214
  setup-deps Настроить dependabot/renovate
215
+ setup-ci Сгенерировать .github/workflows/ci.yml
216
+ setup-branch-protection Настроить защиту главной ветки (gh api)
217
+ audit Проверить теги/Releases/демо/README на пробелы
218
+ fix-releases Создать GitHub Release для тегов без Release
178
219
 
179
220
  🔧 РАЗРАБОТКА:
180
221
  update-readme Обновить README с версией
@@ -205,6 +246,9 @@ if (commands[command]) {
205
246
  ⚙️ КОНФИГУРАЦИЯ:
206
247
  jst.config.js Настройки веток, labels, коммитов, релизов
207
248
 
249
+ 🚩 ГЛОБАЛЬНЫЕ ФЛАГИ:
250
+ --no-audit Отключить release audit для этого запуска
251
+
208
252
  📝 ФОРМАТ КОММИТОВ:
209
253
  feat: #9 описание Фича (ссылка на issue)
210
254
  feat(scope): #9 описание Фича со scope
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vv0rkz/js-template",
3
- "version": "1.8.4",
3
+ "version": "1.10.0",
4
4
  "description": "Reusable setup for JS projects with husky, changelog, gh tools",
5
5
  "type": "module",
6
6
  "bin": {
@@ -103,6 +103,23 @@ export default {
103
103
  */
104
104
  style: 'click',
105
105
  },
106
+
107
+ /**
108
+ * Release audit — пассивная проверка перед каждой jst-командой.
109
+ * Сравнивает git tags ↔ GitHub Releases ↔ демо-файлы ↔ README-секции
110
+ * и печатает предупреждения, не блокируя команду.
111
+ *
112
+ * enable — включить хук (true | false)
113
+ * warnOnly — только предупреждать (true) или падать с кодом 1 (false)
114
+ *
115
+ * Отключить разово: `jst --no-audit <command>`
116
+ * Запустить явно: `jst audit`
117
+ * Починить пропуски: `jst fix-releases`
118
+ */
119
+ audit: {
120
+ enable: true,
121
+ warnOnly: true,
122
+ },
106
123
  },
107
124
 
108
125
  /**
@@ -121,12 +138,55 @@ export default {
121
138
 
122
139
  /**
123
140
  * Автообновление зависимостей
124
- * @type {'dependabot' | 'renovate' | false}
125
141
  *
126
- * 'dependabot' — создаст .github/dependabot.yml
127
- * 'renovate' — создаст renovate.json
128
- * false отключено
142
+ * Короткая форма:
143
+ * depUpdater: 'dependabot' — создаст .github/dependabot.yml
144
+ * depUpdater: 'renovate' создаст renovate.json
145
+ * depUpdater: false — отключено
146
+ *
147
+ * Расширенная форма (только dependabot):
148
+ * depUpdater: {
149
+ * type: 'dependabot',
150
+ * ignoreMajor: true, // не открывать major PR-ы
151
+ * ignore: { major: true, minor: false }, // или более гранулярно
152
+ * autoMerge: { patch: true, minor: true, major: false },
153
+ * }
154
+ *
155
+ * При `autoMerge` jst дополнительно создаёт
156
+ * .github/workflows/dependabot-auto-merge.yml
129
157
  */
130
158
  depUpdater: false,
131
159
 
160
+ /**
161
+ * Continuous Integration (GitHub Actions)
162
+ *
163
+ * При `enable: true` команда `jst setup-ci` (и `jst apply`) создаёт
164
+ * .github/workflows/ci.yml — workflow с по одной job на каждый check.
165
+ * Имя job совпадает с именем npm-скрипта, который оно запускает.
166
+ *
167
+ * ci.yml: jobs.lint → runs `npm run lint`
168
+ * jobs.test → runs `npm run test`
169
+ */
170
+ ci: {
171
+ enable: false,
172
+ checks: ['lint', 'test'],
173
+ nodeVersion: '20',
174
+ },
175
+
176
+ /**
177
+ * Branch protection (через `gh api`)
178
+ *
179
+ * Команда `jst setup-branch-protection` настраивает защиту главной ветки
180
+ * через GitHub API. Не запускается автоматически в `jst apply` —
181
+ * требует admin прав на репозитории.
182
+ *
183
+ * requiredChecks — список обязательных status checks (должны совпадать
184
+ * с именами job-ов из ci.checks).
185
+ */
186
+ branchProtection: {
187
+ enable: false,
188
+ requiredChecks: [],
189
+ enforceAdmins: false,
190
+ },
191
+
132
192
  }
@@ -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
 
@@ -25,6 +25,10 @@ const DEFAULTS = {
25
25
  formats: ['gif', 'png'],
26
26
  style: 'click',
27
27
  },
28
+ audit: {
29
+ enable: true,
30
+ warnOnly: true,
31
+ },
28
32
  },
29
33
  changelog: {
30
34
  types: {
@@ -35,6 +39,28 @@ const DEFAULTS = {
35
39
  },
36
40
  },
37
41
  depUpdater: false,
42
+ ci: {
43
+ enable: false,
44
+ checks: ['lint', 'test'],
45
+ nodeVersion: '20',
46
+ },
47
+ branchProtection: {
48
+ enable: false,
49
+ requiredChecks: [],
50
+ enforceAdmins: false,
51
+ },
52
+ }
53
+
54
+ /**
55
+ * Normalizes `depUpdater` to an object form.
56
+ * false → { type: false }
57
+ * 'dependabot' → { type: 'dependabot' }
58
+ * { type, ...opts } → unchanged
59
+ */
60
+ export function normalizeDepUpdater(depUpdater) {
61
+ if (!depUpdater) return { type: false }
62
+ if (typeof depUpdater === 'string') return { type: depUpdater }
63
+ return { type: false, ...depUpdater }
38
64
  }
39
65
 
40
66
  // --- Branch pattern template engine ---
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { execSync, spawnSync } from 'child_process'
3
+ import { platform } from 'os'
4
+
5
+ const isWin = platform() === 'win32'
6
+ const ghCmd = isWin ? 'gh.exe' : 'gh'
7
+
8
+ function safeExec(cmd) {
9
+ try {
10
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
11
+ } catch {
12
+ return ''
13
+ }
14
+ }
15
+
16
+ try {
17
+ execSync(`${ghCmd} auth status`, { stdio: 'ignore' })
18
+ } catch {
19
+ console.error('❌ GitHub CLI не установлен или не авторизован')
20
+ console.log('💡 Установи: https://cli.github.com/ и выполни: gh auth login')
21
+ process.exit(1)
22
+ }
23
+
24
+ const tagsRaw = safeExec('git tag --sort=creatordate')
25
+ const tags = tagsRaw
26
+ .split('\n')
27
+ .map((t) => t.trim())
28
+ .filter((t) => /^v\d+\.\d+\.\d+$/.test(t))
29
+
30
+ if (tags.length === 0) {
31
+ console.log('ℹ️ Тегов не найдено — нечего восстанавливать')
32
+ process.exit(0)
33
+ }
34
+
35
+ const releasesJson = safeExec(`${ghCmd} release list --json tagName --limit 200`)
36
+ let releaseTags = new Set()
37
+ try {
38
+ releaseTags = new Set(JSON.parse(releasesJson || '[]').map((r) => r.tagName))
39
+ } catch {
40
+ console.error('❌ Не удалось распарсить список GitHub Releases')
41
+ process.exit(1)
42
+ }
43
+
44
+ const missing = tags.filter((t) => !releaseTags.has(t))
45
+
46
+ if (missing.length === 0) {
47
+ console.log('✅ Все теги уже имеют GitHub Release')
48
+ process.exit(0)
49
+ }
50
+
51
+ console.log(`🔧 Создаю GitHub Release для ${missing.length} тег(ов):`)
52
+ missing.forEach((t) => console.log(` • ${t}`))
53
+ console.log()
54
+
55
+ let created = 0
56
+ let failed = 0
57
+
58
+ for (const tag of missing) {
59
+ process.stdout.write(` ${tag} ... `)
60
+ const result = spawnSync(ghCmd, ['release', 'create', tag, '--generate-notes', '--title', tag], {
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ encoding: 'utf8',
63
+ })
64
+
65
+ if (result.status === 0) {
66
+ console.log('✅')
67
+ created++
68
+ } else {
69
+ console.log('⚠️')
70
+ const errMsg = (result.stderr || '').trim().split('\n').slice(0, 2).join(' | ')
71
+ if (errMsg) console.log(` ${errMsg}`)
72
+ failed++
73
+ }
74
+ }
75
+
76
+ console.log(`\n📊 Создано: ${created}, ошибок: ${failed}`)
77
+ if (failed > 0) process.exit(1)
@@ -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
  }
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from 'child_process'
3
+ import { existsSync, readFileSync } from 'fs'
4
+ import { platform } from 'os'
5
+ import { loadConfig } from './config.js'
6
+
7
+ const isWin = platform() === 'win32'
8
+ const ghCmd = isWin ? 'gh.exe' : 'gh'
9
+
10
+ const config = await loadConfig()
11
+ const audit = (config.release && config.release.audit) || {}
12
+
13
+ if (audit.enable === false) process.exit(0)
14
+
15
+ // When invoked as a pre-run hook we stay silent on a clean repo. Stand-alone
16
+ // invocations (`jst audit`) print a positive confirmation.
17
+ const quietIfClean = process.argv.includes('--quiet-if-clean')
18
+ const warnOnly = audit.warnOnly !== false
19
+
20
+ function safeExec(cmd) {
21
+ try {
22
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
23
+ } catch {
24
+ return ''
25
+ }
26
+ }
27
+
28
+ const tagsRaw = safeExec('git tag --sort=-creatordate')
29
+ const tags = tagsRaw
30
+ .split('\n')
31
+ .map((t) => t.trim())
32
+ .filter((t) => /^v\d+\.\d+\.\d+$/.test(t))
33
+
34
+ if (tags.length === 0) process.exit(0)
35
+
36
+ let releaseTags = new Set()
37
+ const releasesJson = safeExec(`${ghCmd} release list --json tagName --limit 100`)
38
+ if (releasesJson) {
39
+ try {
40
+ releaseTags = new Set(JSON.parse(releasesJson).map((r) => r.tagName))
41
+ } catch {
42
+ // gh returned non-JSON — treat as "no info", skip the release check
43
+ releaseTags = null
44
+ }
45
+ }
46
+
47
+ const demoCfg = (config.release && config.release.demo) || {}
48
+ const demoEnabled = demoCfg.enable !== false
49
+ const demoDir = demoCfg.dir || 'docs'
50
+ const demoFormats = Array.isArray(demoCfg.formats) && demoCfg.formats.length ? demoCfg.formats : ['gif', 'png']
51
+
52
+ let readmeContent = ''
53
+ let hasAutoSection = false
54
+ if (existsSync('README.md')) {
55
+ readmeContent = readFileSync('README.md', 'utf8')
56
+ hasAutoSection = readmeContent.includes('<!-- AUTOGENERATED_SECTION START -->')
57
+ }
58
+
59
+ const noRelease = []
60
+ const noDemo = []
61
+ const noReadme = []
62
+
63
+ for (const tag of tags) {
64
+ if (releaseTags && !releaseTags.has(tag)) noRelease.push(tag)
65
+
66
+ if (demoEnabled) {
67
+ const hasDemo = demoFormats.some((fmt) => existsSync(`${demoDir}/${tag}.${fmt}`))
68
+ if (!hasDemo) noDemo.push(tag)
69
+ }
70
+
71
+ if (hasAutoSection && !readmeContent.includes(`### 🟢 ${tag}`)) {
72
+ noReadme.push(tag)
73
+ }
74
+ }
75
+
76
+ const total = noRelease.length + noDemo.length + noReadme.length
77
+
78
+ if (total === 0) {
79
+ if (!quietIfClean) console.log('✅ Release audit: всё в порядке')
80
+ process.exit(0)
81
+ }
82
+
83
+ // Compact list helper — first N tags then "+M more"
84
+ function fmtTags(list, limit = 5) {
85
+ if (list.length <= limit) return list.join(', ')
86
+ return list.slice(0, limit).join(', ') + ` +${list.length - limit} more`
87
+ }
88
+
89
+ if (noRelease.length) {
90
+ console.warn(`⚠️ Release audit: ${noRelease.length} тег(ов) без GitHub Release: ${fmtTags(noRelease)}`)
91
+ console.warn(` → Исправить: jst fix-releases`)
92
+ }
93
+ if (noDemo.length) {
94
+ console.warn(`⚠️ Release audit: ${noDemo.length} тег(ов) без демо: ${fmtTags(noDemo)}`)
95
+ console.warn(` → Создай файлы в ${demoDir}/<tag>.${demoFormats[0]}`)
96
+ }
97
+ if (noReadme.length) {
98
+ console.warn(`⚠️ Release audit: README не содержит секции: ${fmtTags(noReadme)}`)
99
+ console.warn(` → Исправить: jst update-readme`)
100
+ }
101
+
102
+ if (!warnOnly) process.exit(1)
103
+ process.exit(0)
@@ -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
+ }