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/CHANGELOG.md +83 -19
- package/lib/client/styles.d.ts +1 -1
- package/lib/client/ui.d.ts +14 -0
- package/lib/client.js +141 -31
- package/lib/domain.d.ts +8 -0
- package/lib/index.d.ts +108 -22
- package/lib/index.js +774 -113
- package/lib/index.js.map +1 -1
- package/locale/en.json +12 -2
- package/locale/zh.json +12 -2
- package/package.json +2 -2
- package/scripts/prepare-release.mjs +318 -128
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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,
|
|
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),
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* CHANGELOG.md (root only — the transports package has no generated
|
|
11
|
-
* changelog),
|
|
12
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
}
|