@skitterbyte/skitterspec 1.0.0 → 2.0.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.
Files changed (42) hide show
  1. package/README.md +27 -244
  2. package/assets/claude-md-section.md +0 -6
  3. package/assets/core/env.config.json.example +5 -1
  4. package/assets/core/env.config.md +21 -5
  5. package/assets/rules/spec-planning.md +14 -10
  6. package/assets/skills/spec/SKILL.md +11 -38
  7. package/assets/skills/spec-complete/SKILL.md +31 -4
  8. package/assets/skills/spec-env/SKILL.md +6 -0
  9. package/assets/skills/spec-env-down/SKILL.md +16 -8
  10. package/assets/skills/spec-go/SKILL.md +15 -17
  11. package/package.json +6 -11
  12. package/src/cli.js +174 -318
  13. package/src/deprecate.js +138 -0
  14. package/src/env/config.js +17 -4
  15. package/src/env/integrate.js +46 -0
  16. package/src/env/resolve.js +54 -45
  17. package/src/env/teardown.js +19 -4
  18. package/src/env/trust.js +87 -0
  19. package/src/init.js +78 -170
  20. package/src/prompts.js +26 -63
  21. package/LICENSE +0 -21
  22. package/assets/core/linear.config.json.example +0 -39
  23. package/assets/core/linear.config.md +0 -121
  24. package/assets/rules/commit-messages.md +0 -85
  25. package/assets/scripts/generate-changelog.js +0 -274
  26. package/assets/scripts/generate-releases.js +0 -360
  27. package/assets/scripts/lib/config.js +0 -127
  28. package/assets/scripts/lib/git-commits.js +0 -265
  29. package/assets/skills/commit/SKILL.md +0 -28
  30. package/assets/skills/spec-pull/SKILL.md +0 -46
  31. package/assets/skills/spec-push/SKILL.md +0 -53
  32. package/assets/skills/spec-status/SKILL.md +0 -46
  33. package/src/config.js +0 -13
  34. package/src/sync/apply.js +0 -66
  35. package/src/sync/base.js +0 -83
  36. package/src/sync/compare.js +0 -99
  37. package/src/sync/config.js +0 -198
  38. package/src/sync/mcp.js +0 -112
  39. package/src/sync/normalize.js +0 -249
  40. package/src/sync/pull.js +0 -84
  41. package/src/sync/push.js +0 -106
  42. package/src/sync/write.js +0 -86
@@ -1,274 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict'
3
-
4
- /**
5
- * Generate a dev-facing CHANGELOG from git commits using conventional commits.
6
- * Run manually or wired into the versioning process. Sibling to
7
- * generate-releases.js (user-facing notes from `Release-Note:` footers).
8
- *
9
- * The output filename is injected (defaults to CHANGELOG.md). skitterspec's
10
- * config loader supplies it in production; the pure functions below are
11
- * filename-agnostic and unit-testable on their own.
12
- */
13
-
14
- const { readFileSync, writeFileSync } = require('node:fs')
15
- const { join } = require('node:path')
16
-
17
- const {
18
- escapeRegex,
19
- getAllVersionTags,
20
- getCommitsBetween,
21
- getCommitsSinceLastTag,
22
- getTagDate,
23
- parseCommit,
24
- } = require('./lib/git-commits.js')
25
- const { loadConfig } = require('./lib/config.js')
26
-
27
- const DEFAULT_FILE = 'CHANGELOG.md'
28
-
29
- function categorizeCommits(commits) {
30
- const categories = {
31
- added: [],
32
- changed: [],
33
- deprecated: [],
34
- removed: [],
35
- fixed: [],
36
- security: [],
37
- other: [],
38
- }
39
-
40
- for (const commit of commits) {
41
- if (commit.breaking) {
42
- // Breaking changes always land under Changed regardless of type.
43
- categories.changed.push(commit)
44
- continue
45
- }
46
-
47
- switch (commit.type) {
48
- case 'feat':
49
- categories.added.push(commit)
50
- break
51
- case 'fix':
52
- categories.fixed.push(commit)
53
- break
54
- case 'perf':
55
- case 'refactor':
56
- categories.changed.push(commit)
57
- break
58
- case 'docs':
59
- case 'style':
60
- case 'test':
61
- case 'chore':
62
- case 'build':
63
- case 'ci':
64
- // Non-user-facing — skipped from the changelog.
65
- break
66
- default:
67
- categories.other.push(commit)
68
- }
69
- }
70
-
71
- return categories
72
- }
73
-
74
- function formatChangelogEntry(entry) {
75
- const scope = entry.scope ? `**${entry.scope}**: ` : ''
76
- return `- ${scope}${entry.message}`
77
- }
78
-
79
- function generateChangelogSection(version, date, categories) {
80
- const sections = []
81
-
82
- sections.push(`## [${version}] - ${date}\n`)
83
-
84
- const ordered = [
85
- ['Added', categories.added],
86
- ['Changed', categories.changed],
87
- ['Deprecated', categories.deprecated],
88
- ['Removed', categories.removed],
89
- ['Fixed', categories.fixed],
90
- ['Security', categories.security],
91
- ]
92
-
93
- for (const [heading, entries] of ordered) {
94
- if (entries.length > 0) {
95
- sections.push(`### ${heading}`)
96
- entries.forEach((entry) => {
97
- sections.push(formatChangelogEntry(entry))
98
- })
99
- sections.push('')
100
- }
101
- }
102
-
103
- return sections.join('\n')
104
- }
105
-
106
- function getCurrentVersion() {
107
- try {
108
- const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
109
- return packageJson.version
110
- } catch {
111
- console.error('Error reading package.json')
112
- process.exit(1)
113
- }
114
- }
115
-
116
- const DEFAULT_HEADER = `# Changelog
117
-
118
- All notable changes to this project will be documented in this file.
119
-
120
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
121
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
122
- `
123
-
124
- function upsertSection(changelogContent, newSection, version) {
125
- const existingRegex = new RegExp(
126
- `(^|\\n)## \\[${escapeRegex(version)}\\][^\\n]*\\n[\\s\\S]*?(?=\\n## \\[|\\n---|$)`,
127
- )
128
- const existingMatch = changelogContent.match(existingRegex)
129
-
130
- if (existingMatch) {
131
- const leading = existingMatch[1] ?? ''
132
- return changelogContent.replace(existingRegex, `${leading}${newSection.trimEnd()}\n`)
133
- }
134
-
135
- const unreleasedRegex = /## \[Unreleased\][\s\S]*?(?=\n## \[|$)/
136
- const unreleasedMatch = changelogContent.match(unreleasedRegex)
137
- if (unreleasedMatch && unreleasedMatch.index !== undefined) {
138
- const insertPos = unreleasedMatch.index + unreleasedMatch[0].length
139
- const before = changelogContent.slice(0, insertPos).replace(/\s+$/, '')
140
- const after = changelogContent.slice(insertPos).replace(/^\s+/, '')
141
- return `${before}\n\n${newSection}\n${after ? `${after}\n` : ''}`
142
- }
143
-
144
- const firstVersionIdx = changelogContent.search(/\n## \[/)
145
- if (firstVersionIdx >= 0) {
146
- const before = changelogContent.slice(0, firstVersionIdx).replace(/\s+$/, '')
147
- const after = changelogContent.slice(firstVersionIdx + 1)
148
- return `${before}\n\n${newSection}\n${after}`
149
- }
150
-
151
- return `${changelogContent.replace(/\s+$/, '')}\n\n${newSection}`
152
- }
153
-
154
- function readChangelog(path) {
155
- try {
156
- return readFileSync(path, 'utf-8')
157
- } catch {
158
- return DEFAULT_HEADER
159
- }
160
- }
161
-
162
- function updateChangelog(newVersion, options = {}) {
163
- const file = options.file || DEFAULT_FILE
164
- const changelogPath = join(process.cwd(), file)
165
- let changelogContent = readChangelog(changelogPath)
166
-
167
- const commitLines = getCommitsSinceLastTag(newVersion)
168
- const commits = commitLines.map(parseCommit).filter((commit) => commit !== null)
169
-
170
- if (commits.length === 0) {
171
- console.log(`No conventional commits found since last tag — skipping ${file} update`)
172
- return
173
- }
174
-
175
- const categories = categorizeCommits(commits)
176
- const date = new Date().toISOString().split('T')[0]
177
- const newSection = generateChangelogSection(newVersion, date, categories).trimEnd() + '\n'
178
-
179
- changelogContent = upsertSection(changelogContent, newSection, newVersion)
180
-
181
- writeFileSync(changelogPath, changelogContent, 'utf-8')
182
- console.log(`✅ Updated ${file} with version ${newVersion}`)
183
- }
184
-
185
- function retroFillChangelog(count, options = {}) {
186
- const file = options.file || DEFAULT_FILE
187
- const changelogPath = join(process.cwd(), file)
188
- let changelogContent = readChangelog(changelogPath)
189
-
190
- const tags = getAllVersionTags()
191
- if (tags.length === 0) {
192
- console.log('No version tags found — nothing to retro-fill')
193
- return
194
- }
195
-
196
- // Walk newest → oldest; for each tag, compute commits since the previous tag.
197
- // Write oldest-first so that when we upsert, the newest ends up on top.
198
- const targets = tags.slice(0, count).reverse()
199
- let updated = 0
200
-
201
- for (const tag of targets) {
202
- const idx = tags.indexOf(tag)
203
- const previousTag = idx < tags.length - 1 ? tags[idx + 1] : null
204
- const version = tag.replace(/^v/, '')
205
- const commits = getCommitsBetween(previousTag, tag)
206
- .map(parseCommit)
207
- .filter((c) => c !== null)
208
-
209
- if (commits.length === 0) {
210
- console.log(`⚠️ ${tag}: no conventional commits — skipping`)
211
- continue
212
- }
213
-
214
- const categories = categorizeCommits(commits)
215
- const date = getTagDate(tag)
216
- const section = generateChangelogSection(version, date, categories).trimEnd() + '\n'
217
- changelogContent = upsertSection(changelogContent, section, version)
218
- updated += 1
219
- console.log(`✅ ${tag}: wrote ${commits.length} commit(s)`)
220
- }
221
-
222
- if (updated > 0) {
223
- writeFileSync(changelogPath, changelogContent, 'utf-8')
224
- console.log(`✅ Retro-filled ${updated} release(s) into ${file}`)
225
- }
226
- }
227
-
228
- function main(argv) {
229
- const args = argv.slice(2)
230
-
231
- let config
232
- try {
233
- config = loadConfig()
234
- } catch (error) {
235
- console.error(error.message)
236
- process.exit(1)
237
- }
238
-
239
- if (!config.changelog.enabled) {
240
- console.log('Changelog generation disabled in skitterspec.config.json — skipping')
241
- return
242
- }
243
-
244
- const options = { file: config.changelog.file }
245
- const retroIdx = args.indexOf('--retro')
246
-
247
- if (retroIdx >= 0) {
248
- const countArg = args[retroIdx + 1]
249
- const count = Number.parseInt(countArg ?? '', 10)
250
- if (!Number.isFinite(count) || count <= 0) {
251
- console.error('Usage: generate-changelog.js --retro <count>')
252
- process.exit(1)
253
- }
254
- retroFillChangelog(count, options)
255
- } else {
256
- const version = args[0] || getCurrentVersion()
257
- updateChangelog(version, options)
258
- }
259
- }
260
-
261
- module.exports = {
262
- categorizeCommits,
263
- formatChangelogEntry,
264
- generateChangelogSection,
265
- upsertSection,
266
- updateChangelog,
267
- retroFillChangelog,
268
- DEFAULT_HEADER,
269
- }
270
-
271
- // Run the CLI only when invoked directly (keeps pure functions importable).
272
- if (require.main === module) {
273
- main(process.argv)
274
- }
@@ -1,360 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict'
3
-
4
- /**
5
- * Generate user-facing release notes from `Release-Note:` commit footers.
6
- * Sibling to generate-changelog.js (which builds the dev-facing CHANGELOG from
7
- * commit subjects). Both walk the same tag ranges via lib/git-commits.js.
8
- *
9
- * Opt-in: ONLY commits carrying a `Release-Note:` footer appear here. The dev
10
- * subject stays terse; the footer carries the user-facing sentence.
11
- *
12
- * feat(tasks): explicit state/created dates + sort-by
13
- *
14
- * - Add stateEnteredAt column, sortBy param
15
- *
16
- * Release-Note: You can now sort your task inbox by when an item entered its
17
- * current state or when it was created, with both dates shown on every row.
18
- *
19
- * Footer grammar:
20
- * Release-Note: <text> user-facing note (multi-line via continuation)
21
- * Release-Note!: <text> same, but also promoted into the Highlights line
22
- * Release-Area: <name> override the scope->area mapping
23
- * Release-Note: none explicit "not user-facing" (skipped)
24
- *
25
- * Project-specific values — the scope→area map, the product name in the header,
26
- * and the output filename — are injected (skitterspec's config loader supplies
27
- * them in production). Unmapped scopes fall back to Title-Case of the scope.
28
- */
29
-
30
- const { existsSync, readFileSync, writeFileSync } = require('node:fs')
31
- const { basename, join } = require('node:path')
32
-
33
- const {
34
- escapeRegex,
35
- getAllVersionTags,
36
- getCommitsBetween,
37
- getCommitsSinceLastTag,
38
- getTagDate,
39
- parseCommit,
40
- } = require('./lib/git-commits.js')
41
- const { loadConfig } = require('./lib/config.js')
42
-
43
- // Buckets render in this order within each area.
44
- const BUCKET_ORDER = ['Action required', 'New', 'Improved', 'Fixed']
45
-
46
- const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
47
-
48
- const DEFAULT_FILE = 'RELEASES.md'
49
-
50
- /** A line that looks like a footer key: `Key: ` (stops note continuation). */
51
- const FOOTER_KEY = /^[A-Za-z][\w-]*:\s/
52
-
53
- function defaultProductName() {
54
- return basename(process.cwd())
55
- }
56
-
57
- function defaultReleasesHeader(productName, changelogFile = 'CHANGELOG.md') {
58
- return `# Release Notes
59
-
60
- What's new for users of ${productName}. For the full technical log see
61
- [${changelogFile}](./${changelogFile}).
62
-
63
- Generated from \`Release-Note:\` commit footers.
64
- `
65
- }
66
-
67
- /** Map a conventional type (+breaking flag) to a user bucket, or null to omit. */
68
- function bucketFor(type, breaking) {
69
- if (breaking) return 'Action required'
70
- switch (type) {
71
- case 'feat':
72
- return 'New'
73
- case 'fix':
74
- return 'Fixed'
75
- case 'perf':
76
- case 'refactor':
77
- return 'Improved'
78
- default:
79
- // docs / style / test / chore / build / ci / unknown → never user-facing
80
- return null
81
- }
82
- }
83
-
84
- function titleCase(value) {
85
- return value
86
- .split(/[-_\s]+/)
87
- .filter(Boolean)
88
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
89
- .join(' ')
90
- }
91
-
92
- /**
93
- * Resolve the user-facing area: explicit override wins, else the injected
94
- * scope→area map, else Title-Case of the scope. Missing scope → 'General'.
95
- */
96
- function resolveArea(scope, override, scopeAreas = {}) {
97
- if (override && override.trim()) return override.trim()
98
- if (!scope) return 'General'
99
- return scopeAreas[scope.toLowerCase()] ?? titleCase(scope)
100
- }
101
-
102
- /**
103
- * Extract the user-facing note from a parsed commit, or null if the commit has
104
- * no `Release-Note:` footer, is marked `none`, or is a non-user-facing type.
105
- */
106
- function parseReleaseNote(commit, scopeAreas = {}) {
107
- const bucket = bucketFor(commit.type, commit.breaking)
108
- if (!bucket) return null // omitted type — never user-facing, even with a footer
109
-
110
- const body = commit.body
111
- if (!body) return null
112
-
113
- const lines = body.split('\n')
114
- let noteText = null
115
- let highlight = false
116
- let areaOverride
117
-
118
- for (let i = 0; i < lines.length; i += 1) {
119
- const noteMatch = lines[i].match(/^Release-Note(!)?:\s*(.*)$/i)
120
- if (noteMatch) {
121
- highlight = Boolean(noteMatch[1])
122
- const parts = [noteMatch[2]]
123
- // Gather continuation lines (indented or unindented prose) until a blank
124
- // line, another footer key, or end of body.
125
- for (let j = i + 1; j < lines.length; j += 1) {
126
- const next = lines[j]
127
- if (next.trim() === '') break
128
- if (FOOTER_KEY.test(next)) break
129
- parts.push(next.trim())
130
- }
131
- noteText = parts.join(' ').replace(/\s+/g, ' ').trim()
132
- continue
133
- }
134
- const areaMatch = lines[i].match(/^Release-Area:\s*(.+)$/i)
135
- if (areaMatch) areaOverride = areaMatch[1].trim()
136
- }
137
-
138
- if (!noteText) return null
139
- if (/^none$/i.test(noteText)) return null // explicit not-user-facing marker
140
-
141
- return {
142
- area: resolveArea(commit.scope, areaOverride, scopeAreas),
143
- bucket,
144
- text: noteText,
145
- highlight,
146
- hash: commit.hash,
147
- }
148
- }
149
-
150
- /** ISO `2026-06-19` → friendly `19 Jun 2026` (parsed without Date to avoid TZ shift). */
151
- function formatReleaseDate(isoDate) {
152
- const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})/)
153
- if (!match) return isoDate
154
- const [, year, month, day] = match
155
- return `${Number.parseInt(day, 10)} ${MONTHS[Number.parseInt(month, 10) - 1]} ${year}`
156
- }
157
-
158
- /** Render one release section: heading, optional Highlights, then areas × buckets. */
159
- function renderReleasesSection(version, isoDate, notes) {
160
- const lines = []
161
- lines.push(`## ${version} — ${formatReleaseDate(isoDate)}`)
162
- lines.push('')
163
-
164
- const highlights = notes.filter((n) => n.highlight)
165
- if (highlights.length === 1) {
166
- lines.push(`**Highlights:** ${highlights[0].text}`)
167
- lines.push('')
168
- } else if (highlights.length > 1) {
169
- lines.push('**Highlights:**')
170
- highlights.forEach((h) => lines.push(`- ${h.text}`))
171
- lines.push('')
172
- }
173
-
174
- const areas = [...new Set(notes.map((n) => n.area))].sort((a, b) => a.localeCompare(b))
175
- for (const area of areas) {
176
- lines.push(`### ${area}`)
177
- const areaNotes = notes.filter((n) => n.area === area)
178
- for (const bucket of BUCKET_ORDER) {
179
- areaNotes
180
- .filter((n) => n.bucket === bucket)
181
- .forEach((n) => lines.push(`- **${bucket}** — ${n.text}`))
182
- }
183
- lines.push('')
184
- }
185
-
186
- return lines.join('\n').trimEnd() + '\n'
187
- }
188
-
189
- /** Idempotently insert/replace a release's section by version (newest on top). */
190
- function upsertReleasesSection(content, newSection, version) {
191
- const existingRegex = new RegExp(
192
- `(^|\\n)## ${escapeRegex(version)} [^\\n]*\\n[\\s\\S]*?(?=\\n## \\d|\\n---|$)`,
193
- )
194
- const existingMatch = content.match(existingRegex)
195
- if (existingMatch) {
196
- const leading = existingMatch[1] ?? ''
197
- return content.replace(existingRegex, `${leading}${newSection.trimEnd()}\n`)
198
- }
199
-
200
- // Insert above the newest existing version section (versions start with a digit).
201
- const firstVersionIdx = content.search(/\n## \d/)
202
- if (firstVersionIdx >= 0) {
203
- const before = content.slice(0, firstVersionIdx).replace(/\s+$/, '')
204
- const after = content.slice(firstVersionIdx + 1)
205
- return `${before}\n\n${newSection}\n${after}`
206
- }
207
-
208
- return `${content.replace(/\s+$/, '')}\n\n${newSection}`
209
- }
210
-
211
- function readReleases(path, header) {
212
- try {
213
- return readFileSync(path, 'utf-8')
214
- } catch {
215
- return header
216
- }
217
- }
218
-
219
- function getCurrentVersion() {
220
- try {
221
- const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
222
- return packageJson.version
223
- } catch {
224
- console.error('Error reading package.json')
225
- process.exit(1)
226
- }
227
- }
228
-
229
- function notesFor(commitLines, scopeAreas) {
230
- return commitLines
231
- .map(parseCommit)
232
- .filter((c) => c !== null)
233
- .map((c) => parseReleaseNote(c, scopeAreas))
234
- .filter((n) => n !== null)
235
- }
236
-
237
- function resolveOptions(options = {}) {
238
- return {
239
- file: options.file || DEFAULT_FILE,
240
- productName: options.productName || defaultProductName(),
241
- scopeAreas: options.scopeAreas || {},
242
- changelogFile: options.changelogFile || 'CHANGELOG.md',
243
- }
244
- }
245
-
246
- function updateReleases(newVersion, options = {}) {
247
- const { file, productName, scopeAreas, changelogFile } = resolveOptions(options)
248
- const releasesPath = join(process.cwd(), file)
249
- const header = defaultReleasesHeader(productName, changelogFile)
250
- let content = readReleases(releasesPath, header)
251
-
252
- const notes = notesFor(getCommitsSinceLastTag(newVersion), scopeAreas)
253
- if (notes.length === 0) {
254
- console.log(`No Release-Note footers found since last tag — skipping ${file} update`)
255
- // Ensure the artifact exists so a version hook's downstream steps have a
256
- // file to act on even on a release with no user-facing notes.
257
- if (!existsSync(releasesPath)) writeFileSync(releasesPath, content, 'utf-8')
258
- return
259
- }
260
-
261
- const date = new Date().toISOString().split('T')[0]
262
- const section = renderReleasesSection(newVersion, date, notes)
263
- content = upsertReleasesSection(content, section, newVersion)
264
-
265
- writeFileSync(releasesPath, content, 'utf-8')
266
- console.log(`✅ Updated ${file} with version ${newVersion} (${notes.length} note(s))`)
267
- }
268
-
269
- function retroFillReleases(count, options = {}) {
270
- const { file, productName, scopeAreas, changelogFile } = resolveOptions(options)
271
- const releasesPath = join(process.cwd(), file)
272
- const header = defaultReleasesHeader(productName, changelogFile)
273
- let content = readReleases(releasesPath, header)
274
-
275
- const tags = getAllVersionTags()
276
- if (tags.length === 0) {
277
- console.log('No version tags found — nothing to retro-fill')
278
- return
279
- }
280
-
281
- // Oldest-first so upsert leaves the newest on top.
282
- const targets = tags.slice(0, count).reverse()
283
- let updated = 0
284
-
285
- for (const tag of targets) {
286
- const idx = tags.indexOf(tag)
287
- const previousTag = idx < tags.length - 1 ? tags[idx + 1] : null
288
- const version = tag.replace(/^v/, '')
289
- const notes = notesFor(getCommitsBetween(previousTag, tag), scopeAreas)
290
-
291
- if (notes.length === 0) {
292
- console.log(`⚠️ ${tag}: no Release-Note footers — skipping`)
293
- continue
294
- }
295
-
296
- const section = renderReleasesSection(version, getTagDate(tag), notes)
297
- content = upsertReleasesSection(content, section, version)
298
- updated += 1
299
- console.log(`✅ ${tag}: wrote ${notes.length} note(s)`)
300
- }
301
-
302
- if (updated > 0) {
303
- writeFileSync(releasesPath, content, 'utf-8')
304
- console.log(`✅ Retro-filled ${updated} release(s) into ${file}`)
305
- }
306
- }
307
-
308
- function main(argv) {
309
- const args = argv.slice(2)
310
-
311
- let config
312
- try {
313
- config = loadConfig()
314
- } catch (error) {
315
- console.error(error.message)
316
- process.exit(1)
317
- }
318
-
319
- if (!config.releases.enabled) {
320
- console.log('Release-notes generation disabled in skitterspec.config.json — skipping')
321
- return
322
- }
323
-
324
- const options = {
325
- file: config.releases.file,
326
- productName: config.releases.productName,
327
- scopeAreas: config.releases.scopeAreas,
328
- changelogFile: config.changelog.file,
329
- }
330
- const retroIdx = args.indexOf('--retro')
331
-
332
- if (retroIdx >= 0) {
333
- const count = Number.parseInt(args[retroIdx + 1] ?? '', 10)
334
- if (!Number.isFinite(count) || count <= 0) {
335
- console.error('Usage: generate-releases.js --retro <count>')
336
- process.exit(1)
337
- }
338
- retroFillReleases(count, options)
339
- } else {
340
- updateReleases(args[0] || getCurrentVersion(), options)
341
- }
342
- }
343
-
344
- module.exports = {
345
- bucketFor,
346
- resolveArea,
347
- parseReleaseNote,
348
- formatReleaseDate,
349
- renderReleasesSection,
350
- upsertReleasesSection,
351
- defaultReleasesHeader,
352
- updateReleases,
353
- retroFillReleases,
354
- BUCKET_ORDER,
355
- }
356
-
357
- // Run the CLI only when invoked directly (keeps pure functions importable).
358
- if (require.main === module) {
359
- main(process.argv)
360
- }