dsh-keys-manager 0.2.2 → 0.2.3

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/locale/en.json CHANGED
@@ -3,6 +3,14 @@
3
3
  "title": "Keys Manager",
4
4
  "description": "Multiple API keys per provider with sticky sessions, round-robin selection, retry failover, cooldown recovery, and searchable extra providers."
5
5
  },
6
+ "filterAll": "All",
7
+ "filterBuiltin": "Built-in",
8
+ "filterPlugin": "Plugin",
9
+ "filterOauth": "OAuth-capable",
10
+ "filterTransport": "Transport",
11
+ "unattributed": "{n} unattributed",
12
+ "unattributedHint": "Stream attempts this route served with no account owner because its ownership read had not landed (or had failed). A failover retry is a new attempt, so one request can be counted more than once. The count clears when a read attributes the route.",
13
+ "managedFilteredEmpty": "No managed providers match the current filter.",
6
14
  "addAccount": "Add account",
7
15
  "adoptAccountTitle": "Sign in with an OAuth account to use this provider.",
8
16
  "accountIdentity": "Account",
@@ -28,7 +36,7 @@
28
36
  "deleteAccountTitle": "Delete account “{alias}”?",
29
37
  "deleteAccountBody": "This removes the account’s stored OAuth grant from the credential store. If it is the active account, the provider route stops serving it and falls back to its API-key pool; the provider stays managed.",
30
38
  "deleteAccountBodyNoKeys": "This removes the account’s stored OAuth grant from the credential store. This route has no API-key pool, so it stops serving requests until an account is signed in again.",
31
- "transportAccounts": "Transport accounts",
39
+ "transportProviderHint": "Served by a transport plugin: its credential is managed inside this card.",
32
40
  "transportTag": "Transport",
33
41
  "transportSignIn": "Sign in",
34
42
  "transportExpires": "Expires {time}",
@@ -37,5 +45,7 @@
37
45
  "transportStatusNeedsReauth": "Sign in again",
38
46
  "transportStatusSignedOut": "Not signed in",
39
47
  "transportRemoveTitle": "Remove the {name} transport account?",
40
- "transportRemoveBody": "This removes the grant stored for this transport route from the credential store and cancels a sign-in still in progress; the route does not recreate it. Sign in again to restore service."
48
+ "transportRemoveBody": "This removes the grant stored for this transport route from the credential store and cancels a sign-in still in progress; the route does not recreate it. Sign in again to restore service.",
49
+ "filterGroup": "Filters",
50
+ "transportEmpty": "No transport accounts yet."
41
51
  }
package/locale/zh.json CHANGED
@@ -3,6 +3,14 @@
3
3
  "title": "密钥管理",
4
4
  "description": "为同一个 Provider 配置多个 API Key,支持单会话固定、Round Robin、重试切换、冷却恢复,并提供可搜索的扩展 Provider。"
5
5
  },
6
+ "filterAll": "全部",
7
+ "filterBuiltin": "内置",
8
+ "filterPlugin": "插件",
9
+ "filterOauth": "支持 OAuth",
10
+ "filterTransport": "传输",
11
+ "unattributed": "{n} 无归属",
12
+ "unattributedHint": "该路由在归属读取尚未落地(或已失败)期间应答的流尝试数;失败转移重试会各计一次,故单个请求可能被计多次。读取完成归属后清零。",
13
+ "managedFilteredEmpty": "当前筛选下没有已接管的 Provider。",
6
14
  "addAccount": "添加账户",
7
15
  "adoptAccountTitle": "使用 OAuth 账户登录后即可使用该 Provider。",
8
16
  "accountIdentity": "账户",
@@ -28,7 +36,7 @@
28
36
  "deleteAccountTitle": "删除账户“{alias}”?",
29
37
  "deleteAccountBody": "这会从凭证库中删除该账户保存的 OAuth 授权。如果它是当前账户,该 Provider 路由将停用它并回落到 API-key 池;Provider 仍保持接管状态。",
30
38
  "deleteAccountBodyNoKeys": "这会从凭证库中删除该账户保存的 OAuth 授权。该路由没有 API-key 池,删除后将停止服务,直到重新登录账户。",
31
- "transportAccounts": "传输账户",
39
+ "transportProviderHint": "该路由由传输插件提供,凭证就在本卡片中管理。",
32
40
  "transportTag": "传输",
33
41
  "transportSignIn": "登录",
34
42
  "transportExpires": "有效期至 {time}",
@@ -37,5 +45,7 @@
37
45
  "transportStatusNeedsReauth": "需重新登录",
38
46
  "transportStatusSignedOut": "未登录",
39
47
  "transportRemoveTitle": "移除 {name} 传输账户?",
40
- "transportRemoveBody": "这会从凭证库中删除该传输路由保存的授权,并取消仍在进行的登录;路由自身不会重建它。要恢复服务需要重新登录。"
48
+ "transportRemoveBody": "这会从凭证库中删除该传输路由保存的授权,并取消仍在进行的登录;路由自身不会重建它。要恢复服务需要重新登录。",
49
+ "filterGroup": "筛选",
50
+ "transportEmpty": "还没有传输账户。"
41
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-keys-manager",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Multi-key management for DSH providers: sticky per-session round-robin, retry failover, cooldown recovery, and searchable extra providers.",
6
6
  "repository": {
@@ -72,7 +72,7 @@
72
72
  "react": "^18.2.0"
73
73
  },
74
74
  "dependencies": {
75
- "dsh-llm-omp-transports": "^0.2.2"
75
+ "dsh-llm-omp-transports": "^0.2.3"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@deepseek-ai/cordis": "^4.0.5",
@@ -1,15 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Release-prep version helper (plans dsh-keys-manager-ci-release, task 2 + 4;
4
- * omp-transport-cursor-tools, task 5):
3
+ * Release-prep version and changelog helper (plans dsh-keys-manager-ci-release,
4
+ * task 2 + 4; omp-transport-cursor-tools, task 5; release-changelog-fragments,
5
+ * task 1):
5
6
  * resolves the target version — explicit `X.Y.Z` / `X.Y.Z-prerelease`
6
7
  * argument (e.g. 0.1.1-alpha.1) or an auto patch bump from package.json —
7
- * refuses already-released versions (existing `v<version>` git tag), aborts
8
- * when there are no commits since the previous release tag (nothing to
9
- * release), bumps package.json, inserts the `## [<version>]` section into
10
- * CHANGELOG.md (root only — the transports package has no generated
11
- * changelog), and prints the root and transports versions and tags for the
12
- * workflow to capture. Node built-ins only, no new dependencies.
8
+ * refuses already-released versions (existing `v<version>` git tag), bumps
9
+ * package.json, assembles the release notes from the authored
10
+ * `.changes/unreleased/*.md` fragments, inserts the `## [<version>]` section
11
+ * into CHANGELOG.md (root only — the transports package has no generated
12
+ * changelog), archives the consumed fragments to `.changes/archive/<version>/`,
13
+ * and prints the root and transports versions and tags for the workflow to
14
+ * capture. Node built-ins only, no new dependencies.
15
+ *
16
+ * The fragments ARE the release notes: a release whose rendered body is empty
17
+ * (zero fragments, prose-only fragments, or fragments without `- ` bullet
18
+ * lines) is refused on both a dry run and a real run, so an empty
19
+ * `## [<version>]` section can never be published. Archiving never overwrites
20
+ * an existing `.changes/archive/<version>/` file, and a failed archive reports
21
+ * what moved and what did not. A run prints `ARCHIVED=1` only when it really
22
+ * consumed the fragments.
13
23
  *
14
24
  * The workspace package `packages/llm-omp` (`dsh-llm-omp-transports`) ships as
15
25
  * part of the same release and always carries the same version: the resolved
@@ -25,13 +35,22 @@
25
35
  * node scripts/prepare-release.mjs --dry-run # validate + print, write nothing
26
36
  *
27
37
  * Exit codes: 0 = bumped (or dry-run resolved) and printed versions; 1 =
28
- * rejected (unparseable version, existing tag, no commits since the previous
29
- * release tag, unparseable current package.json version, invalid prerelease
30
- * dist-tag, or CHANGELOG.md write error).
38
+ * rejected (unparseable version, existing tag, no changelog bullets in
39
+ * `.changes/unreleased/`, an archive destination that already exists, an
40
+ * archive move that failed, unparseable current package.json version, invalid
41
+ * prerelease dist-tag, or CHANGELOG.md write error).
31
42
  */
32
43
 
33
44
  import { execFileSync } from 'node:child_process'
34
- import { readFileSync, writeFileSync } from 'node:fs'
45
+ import {
46
+ existsSync,
47
+ mkdirSync,
48
+ readdirSync,
49
+ readFileSync,
50
+ realpathSync,
51
+ renameSync,
52
+ writeFileSync,
53
+ } from 'node:fs'
35
54
  import { dirname, join } from 'node:path'
36
55
  import { fileURLToPath } from 'node:url'
37
56
 
@@ -41,7 +60,10 @@ const TRANSPORTS_PKG_PATH = join(REPO_ROOT, 'packages/llm-omp/package.json')
41
60
  const TRANSPORT_PACKAGE = 'dsh-llm-omp-transports'
42
61
  const CHANGELOG_PATH = join(REPO_ROOT, 'CHANGELOG.md')
43
62
  const CHANGELOG_HEADER =
44
- '# Changelog\n\nAll notable changes to dsh-keys-manager are documented here. Generated from git log by the release-prep workflow.\n'
63
+ '# Changelog\n\nAll notable changes to dsh-keys-manager are documented here. Assembled from `.changes/unreleased/` fragments by the release-prep workflow.\n'
64
+ const UNRELEASED_DIR = join(REPO_ROOT, '.changes/unreleased')
65
+ const ARCHIVE_DIR = join(REPO_ROOT, '.changes/archive')
66
+ const DEFAULT_CATEGORY = 'Changed'
45
67
 
46
68
  /**
47
69
  * Parse X.Y.Z or X.Y.Z[-prerelease] (numeric parts; optional prerelease
@@ -81,24 +103,6 @@ function assertPublishablePrerelease(prerelease) {
81
103
  }
82
104
  }
83
105
 
84
- const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
85
- const transportsPkg = JSON.parse(readFileSync(TRANSPORTS_PKG_PATH, 'utf8'))
86
-
87
- const { versionArg, dryRun } = parseArgs(process.argv.slice(2))
88
-
89
- const target = resolveTarget(versionArg, pkg.version, 'package.json')
90
- const version = formatVersion(target)
91
-
92
- // Validate the npm dist-tag derived by the release workflow. A prerelease
93
- // identifier like "latest" would move the stable tag, and a numeric one like
94
- // "1" is not a legal npm tag. Reject both before the release PR is opened.
95
- assertPublishablePrerelease(target.prerelease)
96
-
97
- // The transports package ships from the same version as the root: one input
98
- // drives both package.json files, so the two can never drift.
99
- const transportVersion = version
100
- const transportTag = `${TRANSPORT_PACKAGE}@${transportVersion}`
101
-
102
106
  /**
103
107
  * Parse CLI flags. The first non-flag argument is the target version.
104
108
  */
@@ -193,122 +197,150 @@ function tagExists(tag) {
193
197
  }
194
198
  }
195
199
 
196
- // Reject already-released versions: the tag exists iff the version was
197
- // released. Works unchanged for prerelease tags (v0.1.1-alpha.1 is a plain
198
- // tag name). Run from REPO_ROOT so the check is cwd-independent.
199
- if (tagExists(`v${version}`)) {
200
- console.error(`Error: tag v${version} already exists — this version is already released.`)
201
- process.exit(1)
200
+ /**
201
+ * Split an optional leading `---` frontmatter block off a fragment. Only
202
+ * `key: value` lines are read; the rest of the file is the body.
203
+ */
204
+ function parseFrontmatter(text) {
205
+ const frontmatter = {}
206
+ if (!text.startsWith('---')) return { frontmatter, body: text }
207
+ const end = text.indexOf('\n---', 3)
208
+ if (end === -1) return { frontmatter, body: text }
209
+ for (const line of text.slice(3, end).split('\n')) {
210
+ const match = /^([A-Za-z0-9 _-]+):\s*(.*)$/.exec(line)
211
+ if (match) frontmatter[match[1].trim()] = match[2].trim()
212
+ }
213
+ return { frontmatter, body: text.slice(end + 4).replace(/^\n/, '') }
202
214
  }
203
- if (tagExists(transportTag)) {
204
- console.error(`Error: tag ${transportTag} already exists — this transport version is already released.`)
205
- process.exit(1)
215
+
216
+ /**
217
+ * One fragment file -> its grouping category (undefined = default), its bullet
218
+ * lines, and how many other non-blank body lines were ignored. Only lines that
219
+ * start with `- ` after trimming (and carry text after the dash) are release
220
+ * notes; prose cannot pass for a bullet, so a fragment of prose renders an
221
+ * empty body and the caller refuses the release instead of publishing a
222
+ * paragraph as a changelog entry.
223
+ */
224
+ export function parseFragment(file, text) {
225
+ const { frontmatter, body } = parseFrontmatter(text)
226
+ const bullets = []
227
+ let dropped = 0
228
+ for (const line of body.split('\n')) {
229
+ const trimmed = line.trim()
230
+ if (trimmed.length === 0) continue
231
+ if (trimmed.startsWith('- ') && trimmed.slice(2).trim().length > 0) bullets.push(trimmed)
232
+ else dropped += 1
233
+ }
234
+ return {
235
+ file,
236
+ category: frontmatter.category?.trim() || undefined,
237
+ bullets,
238
+ dropped,
239
+ }
206
240
  }
207
241
 
208
- // Fail fast on a no-change release: an empty notes range means no commits
209
- // since the previous release tag — prepare-release must not bump the version
210
- // or touch CHANGELOG.md for an empty release (no empty release PR). First-
211
- // ever releases (no ancestor tag yet) span the full first-parent history,
212
- // which is non-empty by construction, so they still pass.
213
- const { prevTag, notes } = resolveReleaseNotes()
214
- if (notes === '') {
215
- console.error(
216
- `Error: no commits since previous release tag ${prevTag === '' ? '<first commit>' : prevTag} — nothing to release. Aborting.`,
217
- )
218
- process.exit(1)
242
+ /**
243
+ * Fragments in filename order. `README.md` (the format doc) and every
244
+ * non-`.md` entry (`.gitkeep`) are skipped; a missing directory is zero
245
+ * fragments, which the caller refuses for a real release.
246
+ */
247
+ export function readFragments(dir = UNRELEASED_DIR) {
248
+ if (!existsSync(dir)) return []
249
+ return readdirSync(dir)
250
+ .filter((file) => file.endsWith('.md') && file !== 'README.md')
251
+ .sort()
252
+ .map((file) => parseFragment(file, readFileSync(join(dir, file), 'utf8')))
219
253
  }
220
254
 
221
- // Write only outside a dry run: a dry run validates and prints the resolved
222
- // versions exactly like a real run, then leaves the working tree untouched.
223
- if (!dryRun) {
224
- pkg.version = version
225
- // Preserve repo formatting: 2-space indent + trailing newline. Bumping only
226
- // the version keeps `git diff package.json` to that one line.
227
- writeFileSync(PKG_PATH, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
255
+ /**
256
+ * Render the section body: `### <category>` + its bullets, categories in
257
+ * first-seen order (a fragment without `category:` lands under `Changed`).
258
+ * A fragment with no bullets contributes nothing — not even a bullet-less
259
+ * heading — so "every fragment is empty" really renders an empty body and the
260
+ * caller can refuse it.
261
+ */
262
+ export function buildSectionBody(fragments) {
263
+ const groups = []
264
+ for (const fragment of fragments) {
265
+ if (fragment.bullets.length === 0) continue
266
+ const category = fragment.category ?? DEFAULT_CATEGORY
267
+ let group = groups.find((candidate) => candidate.category === category)
268
+ if (group === undefined) {
269
+ group = { category, bullets: [] }
270
+ groups.push(group)
271
+ }
272
+ group.bullets.push(...fragment.bullets)
273
+ }
274
+ return groups
275
+ .map((group) => [`### ${group.category}`, '', ...group.bullets].join('\n'))
276
+ .join('\n\n')
277
+ }
228
278
 
229
- transportsPkg.version = transportVersion
230
- writeFileSync(TRANSPORTS_PKG_PATH, `${JSON.stringify(transportsPkg, null, 2)}\n`, 'utf8')
279
+ /**
280
+ * Why this fragment set cannot be released, or null when it can. The rendered
281
+ * body IS the release notes, so an empty body — no fragments at all, fragments
282
+ * that are prose only, or fragments whose `- ` lines carry no text — would
283
+ * publish an empty section: refuse it. The message names the files that
284
+ * contributed nothing. Exported so a caller (CI, a test) can ask the same
285
+ * question without running the release flow.
286
+ */
287
+ export function notesRefusal(fragments) {
288
+ if (buildSectionBody(fragments).trim() !== '') return null
289
+ const empties = fragments
290
+ .filter((fragment) => fragment.bullets.length === 0)
291
+ .map((fragment) => fragment.file)
292
+ return (
293
+ 'no changelog bullets in .changes/unreleased/ — nothing to release. ' +
294
+ 'Add one `- ` bullet per user-visible change (format: .changes/unreleased/README.md).' +
295
+ (empties.length === 0 ? '' : ` Fragment files without bullets: ${empties.join(', ')}.`)
296
+ )
231
297
  }
232
298
 
233
299
  /**
234
- * Resolve the previous release tag (nearest ancestor tag of HEAD; empty
235
- * when none exists yet) and the first-parent commit subjects since that
236
- * tag (full first-parent history when no tag exists). Same git-log snippet
237
- * the workflows use for the PR body — the CHANGELOG section and the PR body
238
- * stay consistent. Do NOT use "v$VERSION^": the version string is not a
239
- * git ref — the bump commit is not tagged yet.
300
+ * Insert a `## [<version>] - <date>` section above the first existing
301
+ * `## [` section (or after the header block when there is none yet).
302
+ * Everything at and below the insertion point keeps its bytes, including the
303
+ * file's trailing-newline state (a split/splice/join round-trip would
304
+ * re-normalize line breaks).
240
305
  */
241
- function resolveReleaseNotes() {
242
- let prevTag = ''
243
- try {
244
- prevTag = execFileSync('git', ['describe', '--tags', '--abbrev=0', 'HEAD'], {
245
- cwd: REPO_ROOT,
246
- encoding: 'utf8',
247
- stdio: ['ignore', 'pipe', 'ignore'],
248
- }).trim()
249
- } catch {
250
- // No ancestor tag yet → full history.
306
+ export function insertSection(changelog, version, date, body) {
307
+ const header = `## [${version}] - ${date}`
308
+ const section = body.trim() === '' ? `${header}\n` : `${header}\n\n${body.trim()}\n`
309
+ const firstSectionMatch = /^## \[/m.exec(changelog)
310
+ if (firstSectionMatch === null) {
311
+ // No sections yet — append the new section after the header block.
312
+ return changelog.endsWith('\n')
313
+ ? `${changelog}\n${section}`
314
+ : `${changelog}\n\n${section}`
251
315
  }
252
- const range = prevTag === '' ? 'HEAD' : `${prevTag}..HEAD`
253
- const notes = execFileSync('git', ['log', '--oneline', '--first-parent', range], {
254
- cwd: REPO_ROOT,
255
- encoding: 'utf8',
256
- stdio: ['ignore', 'pipe', 'ignore'],
257
- }).trim()
258
- return { prevTag, notes }
316
+ return `${changelog.slice(0, firstSectionMatch.index)}${section}\n${changelog.slice(firstSectionMatch.index)}`
259
317
  }
260
318
 
261
319
  /**
262
- * Insert a `## [<version>] - <YYYY-MM-DD>` section under the `# Changelog`
263
- * header, above the existing sections, from the pre-collected git-log notes
264
- * (guaranteed non-empty by the no-change guard, so no fallback text is ever
265
- * written). Preserves all existing sections; no-op when the section for this
266
- * version already exists (idempotent re-runs must not duplicate it); write
267
- * failures exit 1.
320
+ * Write the new section into CHANGELOG.md and report whether it was written.
321
+ * Preserves all existing sections; no-op (returns false) when the section for
322
+ * this version already exists, so a re-run must not duplicate it and must not
323
+ * consume fragments it never rendered. A 0-byte or whitespace-only file (e.g.
324
+ * manual truncation) is treated as missing, so it still boots with the
325
+ * standard header instead of a section above no header at all. Write failures
326
+ * exit 1.
268
327
  */
269
- function updateChangelog(version, notes) {
328
+ function updateChangelog(version, body) {
270
329
  let content
271
330
  try {
272
331
  content = readFileSync(CHANGELOG_PATH, 'utf8')
273
332
  } catch {
274
333
  content = CHANGELOG_HEADER
275
334
  }
276
- // A 0-byte or whitespace-only CHANGELOG.md (e.g. manual truncation) is
277
- // treated as missing: such a file must still bootstrap with the standard
278
- // header, otherwise the new section would land at the top with leading
279
- // blank lines and no `# Changelog` header.
280
335
  if (content.trim() === '') {
281
336
  content = CHANGELOG_HEADER
282
337
  }
283
338
 
284
339
  if (content.includes(`## [${version}]`)) {
285
- return
340
+ return false
286
341
  }
287
342
 
288
- const sectionHeader = `## [${version}] - ${new Date().toISOString().slice(0, 10)}`
289
- const sectionBody = notes
290
- .split('\n')
291
- .map((line) => `- ${line}`)
292
- .join('\n')
293
- // The section's own trailing `\n` plus the inserted `\n` below form the
294
- // blank-line separator between the new section and the existing sections.
295
- const section = `${sectionHeader}\n\n${sectionBody}\n`
296
-
297
- // Insert on the first `## [` section header via string slice: everything
298
- // at and below the insertion point — including the file's exact trailing
299
- // newline — is preserved byte-for-byte. (A split('\n')/splice/join('\n')
300
- // round-trip would re-normalize line breaks and could alter the tail, e.g.
301
- // appending a trailing newline that was not in the source file.)
302
- const firstSectionMatch = /^## \[/m.exec(content)
303
- const firstSectionIdx = firstSectionMatch === null ? -1 : firstSectionMatch.index
304
- let next
305
- if (firstSectionIdx === -1) {
306
- // No sections yet — append the new section after the header block,
307
- // keeping the file's own trailing-newline state.
308
- next = content.endsWith('\n') ? `${content}\n${section}` : `${content}\n\n${section}`
309
- } else {
310
- next = `${content.slice(0, firstSectionIdx)}${section}\n${content.slice(firstSectionIdx)}`
311
- }
343
+ const next = insertSection(content, version, new Date().toISOString().slice(0, 10), body)
312
344
 
313
345
  try {
314
346
  writeFileSync(CHANGELOG_PATH, next, 'utf8')
@@ -316,15 +348,173 @@ function updateChangelog(version, notes) {
316
348
  console.error(`Error: cannot write ${CHANGELOG_PATH}: ${error.message}`)
317
349
  process.exit(1)
318
350
  }
351
+ return true
319
352
  }
320
353
 
321
- // Root changelog only: the transports package has no generated changelog.
322
- if (!dryRun) updateChangelog(version, notes)
354
+ /**
355
+ * Move the consumed fragments to `.changes/archive/<version>/`.
356
+ *
357
+ * Authored release notes are never overwritten: a destination file that
358
+ * already exists refuses the whole move before anything is renamed, so a
359
+ * collision cannot leave the fragments half-archived. A move that fails
360
+ * mid-way reports exactly which files were archived and which remain, then
361
+ * exits 1 — a partially archived set must not be silently reported as done.
362
+ * `FS_*` is not needed for that; the paths are printed verbatim.
363
+ */
364
+ function archiveFragments(version, fragments) {
365
+ if (fragments.length === 0) return true
366
+ const destination = join(ARCHIVE_DIR, version)
367
+ const moves = fragments.map((fragment) => ({
368
+ file: fragment.file,
369
+ from: join(UNRELEASED_DIR, fragment.file),
370
+ to: join(destination, fragment.file),
371
+ }))
323
372
 
324
- // The workflow captures VERSION; the remaining lines are informational and
325
- // let a dry run show both derived tags without touching the tree.
326
- if (dryRun) console.log('DRY_RUN=1')
327
- console.log(`VERSION=${version}`)
328
- console.log(`TAG=v${version}`)
329
- console.log(`TRANSPORT_VERSION=${transportVersion}`)
330
- console.log(`TRANSPORT_TAG=${transportTag}`)
373
+ const collisions = moves.filter((move) => existsSync(move.to)).map((move) => move.to)
374
+ if (collisions.length > 0) {
375
+ console.error(
376
+ `Error: refusing to overwrite archived release notes: ${collisions.join(', ')}. ` +
377
+ 'Move the existing files away (or pick another version) and re-run.',
378
+ )
379
+ process.exit(1)
380
+ }
381
+
382
+ const fail = (error, archived) => {
383
+ const remaining = moves.slice(archived.length).map((candidate) => candidate.from)
384
+ console.error(`Error: cannot archive fragments: ${error.message}`)
385
+ console.error(
386
+ `Archived (${archived.length}): ${archived.length === 0 ? 'none' : archived.join(', ')}.`,
387
+ )
388
+ console.error(`Remaining (${remaining.length}): ${remaining.join(', ')}.`)
389
+ process.exit(1)
390
+ }
391
+
392
+ const archived = []
393
+ try {
394
+ mkdirSync(destination, { recursive: true })
395
+ } catch (error) {
396
+ fail(error, archived)
397
+ }
398
+ for (const move of moves) {
399
+ try {
400
+ renameSync(move.from, move.to)
401
+ } catch (error) {
402
+ fail(error, archived)
403
+ }
404
+ archived.push(move.file)
405
+ }
406
+ return true
407
+ }
408
+
409
+ function main() {
410
+ const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
411
+ const transportsPkg = JSON.parse(readFileSync(TRANSPORTS_PKG_PATH, 'utf8'))
412
+
413
+ const { versionArg, dryRun } = parseArgs(process.argv.slice(2))
414
+
415
+ const target = resolveTarget(versionArg, pkg.version, 'package.json')
416
+ const version = formatVersion(target)
417
+
418
+ // Validate the npm dist-tag derived by the release workflow. A prerelease
419
+ // identifier like "latest" would move the stable tag, and a numeric one like
420
+ // "1" is not a legal npm tag. Reject both before the release PR is opened.
421
+ assertPublishablePrerelease(target.prerelease)
422
+
423
+ // The transports package ships from the same version as the root: one input
424
+ // drives both package.json files, so the two can never drift.
425
+ const transportVersion = version
426
+ const transportTag = `${TRANSPORT_PACKAGE}@${transportVersion}`
427
+
428
+ // Reject already-released versions: the tag exists iff the version was
429
+ // released. Works unchanged for prerelease tags (v0.1.1-alpha.1 is a plain
430
+ // tag name). Run from REPO_ROOT so the check is cwd-independent.
431
+ if (tagExists(`v${version}`)) {
432
+ console.error(`Error: tag v${version} already exists — this version is already released.`)
433
+ process.exit(1)
434
+ }
435
+ if (tagExists(transportTag)) {
436
+ console.error(`Error: tag ${transportTag} already exists — this transport version is already released.`)
437
+ process.exit(1)
438
+ }
439
+
440
+ // The fragments are the only release-notes source, so a release whose
441
+ // rendered body is empty — no fragments at all, or only fragments with no
442
+ // bullet lines — would publish an empty `## [<version>]` section: refuse
443
+ // instead, on a dry run too (a dry run is release validation, not an
444
+ // exemption). Prose that looks like notes is not a bullet; it is reported
445
+ // below and never rendered.
446
+ const fragments = readFragments()
447
+ for (const fragment of fragments) {
448
+ if (fragment.dropped > 0) {
449
+ console.error(
450
+ `Notice: ${fragment.file}: ignored ${fragment.dropped} non-bullet line(s) — notes publish only "- " bullets.`,
451
+ )
452
+ }
453
+ }
454
+ const refusal = notesRefusal(fragments)
455
+ if (refusal !== null) {
456
+ console.error(`Error: ${refusal}`)
457
+ process.exit(1)
458
+ }
459
+ const body = buildSectionBody(fragments)
460
+
461
+ // Whether this run actually moved the fragments into the archive, so a
462
+ // caller can tell "release prepared" from "nothing consumed" (dry run, or an
463
+ // existing section for this version) without guessing from the tree.
464
+ let archived = false
465
+
466
+ // Write only outside a dry run: a dry run validates and prints the resolved
467
+ // versions exactly like a real run, then leaves the working tree untouched.
468
+ if (!dryRun) {
469
+ pkg.version = version
470
+ // Preserve repo formatting: 2-space indent + trailing newline. Bumping only
471
+ // the version keeps `git diff package.json` to that one line.
472
+ writeFileSync(PKG_PATH, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
473
+
474
+ transportsPkg.version = transportVersion
475
+ writeFileSync(TRANSPORTS_PKG_PATH, `${JSON.stringify(transportsPkg, null, 2)}\n`, 'utf8')
476
+
477
+ // Root changelog only: the transports package has no generated changelog.
478
+ // Fragments are archived only when this run rendered them: an existing
479
+ // section (idempotent re-run) must not consume them unrendered.
480
+ if (updateChangelog(version, body)) {
481
+ archived = archiveFragments(version, fragments)
482
+ } else {
483
+ console.error(
484
+ `Notice: CHANGELOG.md already has a "## [${version}]" section — fragments left in .changes/unreleased/: ${fragments.map((fragment) => fragment.file).join(', ')}.`,
485
+ )
486
+ }
487
+ }
488
+
489
+ // The workflow captures VERSION; the remaining lines are informational and
490
+ // let a dry run show both derived tags without touching the tree.
491
+ if (dryRun) console.log('DRY_RUN=1')
492
+ console.log(`FRAGMENTS=${fragments.length}`)
493
+ console.log(`VERSION=${version}`)
494
+ console.log(`TAG=v${version}`)
495
+ console.log(`TRANSPORT_VERSION=${transportVersion}`)
496
+ console.log(`TRANSPORT_TAG=${transportTag}`)
497
+ console.log(`ARCHIVED=${archived ? 1 : 0}`)
498
+ }
499
+
500
+ /**
501
+ * Whether this module is the entry point of the running process. Compared by
502
+ * real path, not by string: a symlinked checkout (macOS `/tmp` ->
503
+ * `/private/tmp`, a linked worktree, an npm bin shim) runs `argv[1]` through a
504
+ * different path than `import.meta.url`, and a string comparison would silently
505
+ * no-op the whole release flow. Importing the module (tests) stays a no-op.
506
+ */
507
+ function isDirectExecution() {
508
+ if (process.argv[1] === undefined) return false
509
+ try {
510
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
511
+ } catch {
512
+ return false
513
+ }
514
+ }
515
+
516
+ // Run only when executed directly (node scripts/prepare-release.mjs):
517
+ // importing the module (tests) must not start the release flow.
518
+ if (isDirectExecution()) {
519
+ main()
520
+ }