zames_pro 2.75.5 → 2.76.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 (82) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +10 -4
  3. package/dist/agent-loop.js +61 -1
  4. package/dist/browser.js +40 -8
  5. package/dist/commands.js +0 -1
  6. package/dist/config.js +8 -3
  7. package/dist/context.js +99 -0
  8. package/dist/deepseek-ui.js +1 -3
  9. package/dist/i18n/common.js +13 -16
  10. package/dist/i18n/config.js +2 -2
  11. package/dist/i18n/help.js +48 -6
  12. package/dist/i18n/spinner.js +7 -1
  13. package/dist/index.js +10 -50
  14. package/dist/net-capture.js +43 -11
  15. package/dist/provider-handoff.js +0 -4
  16. package/dist/qwen-ui.js +0 -8
  17. package/dist/run-task.js +24 -1
  18. package/dist/shell.js +0 -6
  19. package/dist/system-prompt.js +52 -2
  20. package/dist/tools.js +33 -0
  21. package/dist/tui/AskQuestion.js +108 -0
  22. package/dist/tui/FullScreenApp.js +2 -1
  23. package/dist/tui/InputLine.js +0 -3
  24. package/dist/tui/StatusBar.js +0 -9
  25. package/dist/tui/TuiEditor.js +4 -43
  26. package/dist/tui/edit.js +13 -22
  27. package/dist/tui/keys.js +12 -14
  28. package/dist/tui/run-ask.js +22 -0
  29. package/dist/tui/store.js +0 -2
  30. package/dist/tui/widgets.js +3 -9
  31. package/package.json +2 -2
  32. package/scripts/build-readme.mts +0 -101
  33. package/scripts/changelog-draft.mts +0 -93
  34. package/scripts/clean-dist.mts +0 -17
  35. package/scripts/coverage-gate.mts +0 -112
  36. package/scripts/ctd-live-driver.mts +0 -51
  37. package/scripts/ctd-live.tsx +0 -33
  38. package/scripts/escd-live-driver.mts +0 -37
  39. package/scripts/eslint-ts6.mts +0 -58
  40. package/scripts/inline-status-driver.mts +0 -31
  41. package/scripts/inline-status.tsx +0 -33
  42. package/scripts/mouse-churn.mts +0 -34
  43. package/scripts/qwen-models-smoke.mts +0 -117
  44. package/scripts/qwen-smoke.mts +0 -221
  45. package/scripts/release.mts +0 -199
  46. package/scripts/render-demo.mts +0 -154
  47. package/scripts/render-how-it-works.mts +0 -229
  48. package/scripts/ru-shot.mts +0 -20
  49. package/scripts/sched-smoke.mts +0 -150
  50. package/scripts/self-smoke.mts +0 -309
  51. package/scripts/tui-comp-shot.mts +0 -63
  52. package/scripts/tui-components.tsx +0 -62
  53. package/scripts/tui-config-driver.mts +0 -82
  54. package/scripts/tui-config-probe.tsx +0 -74
  55. package/scripts/tui-edit-driver.mts +0 -100
  56. package/scripts/tui-edit-probe.tsx +0 -38
  57. package/scripts/tui-editor-probe.tsx +0 -24
  58. package/scripts/tui-input-driver.mts +0 -54
  59. package/scripts/tui-input-shot.tsx +0 -59
  60. package/scripts/tui-log-driver.mts +0 -35
  61. package/scripts/tui-log-shot.tsx +0 -48
  62. package/scripts/tui-longpaste-driver.mts +0 -56
  63. package/scripts/tui-longpaste.tsx +0 -35
  64. package/scripts/tui-manyselect-shot.mts +0 -34
  65. package/scripts/tui-manyselect.tsx +0 -17
  66. package/scripts/tui-mouse-churn-probe.tsx +0 -28
  67. package/scripts/tui-paste-driver.mts +0 -88
  68. package/scripts/tui-paste-kinds.tsx +0 -76
  69. package/scripts/tui-pastetest.mts +0 -38
  70. package/scripts/tui-probe.tsx +0 -62
  71. package/scripts/tui-repro.tsx +0 -64
  72. package/scripts/tui-resize.mts +0 -70
  73. package/scripts/tui-scroll-driver.mts +0 -53
  74. package/scripts/tui-scroll-probe.tsx +0 -46
  75. package/scripts/tui-shot.mts +0 -106
  76. package/scripts/tui-status-driver.mts +0 -24
  77. package/scripts/tui-status-shot.tsx +0 -27
  78. package/scripts/tui-submit.tsx +0 -57
  79. package/scripts/tui-wheel-driver.mts +0 -61
  80. package/scripts/tui-wheel-probe.tsx +0 -48
  81. package/scripts/tui-widgets-driver.mts +0 -20
  82. package/scripts/tui-widgets.tsx +0 -44
@@ -1,33 +0,0 @@
1
- import React from 'react'
2
- import { render } from 'ink'
3
- import { TuiStore } from '../src/tui/store.js'
4
- import { TuiApp } from '../src/tui/TuiApp.js'
5
- import { initialEditState } from '../src/tui/edit.js'
6
- const store = new TuiStore()
7
- store.set({
8
- title: 'z',
9
- version: '1',
10
- workdir: '/tmp',
11
- prompt: '> ',
12
- locale: 'en',
13
- commands: [],
14
- edit: { ...initialEditState(), buf: 'abc' },
15
- statusText: 'STATUS HERE',
16
- })
17
- const p = {
18
- onSubmit: () => {},
19
- onEscape: () => {},
20
- onCtrlC: () => {},
21
- onCopyCode: () => {},
22
- onAttach: () => null,
23
- onClipboard: () => Promise.resolve(null),
24
- onHistoryChange: () => {},
25
- onClearScreen: () => {},
26
- attachments: [],
27
- onPastesChange: () => {},
28
- }
29
- render(React.createElement(TuiApp, { store, inputProps: p }), {
30
- exitOnCtrlC: false,
31
- patchConsole: false,
32
- })
33
- setInterval(() => {}, 1 << 30)
@@ -1,34 +0,0 @@
1
- // Prove the mouse-mode churn is ZERO: mount the REAL TuiEditor, force several
2
- // store updates (like the 1s badge refresh does), and COUNT how many times the
3
- // raw stdout receives the mouse-mode sequences. With the effect depending on
4
- // an inline onWheel, the count grew per render (the wheel-vs-history bug);
5
- // with the ref fix, each mode byte is written ONCE.
6
- //
7
- // Exit code 0 = OK, 1 = churn detected. Prints a one-line verdict so the pty
8
- // test can assert on it.
9
-
10
- import { spawn } from 'node:child_process'
11
- import path from 'node:path'
12
-
13
- const child = spawn(
14
- 'script',
15
- ['-qec', 'npx tsx scripts/tui-mouse-churn-probe.tsx', '/dev/null'],
16
- {
17
- cwd: path.resolve('.'),
18
- env: { ...process.env, COLUMNS: '80', LIMES: '24' },
19
- },
20
- )
21
- let out = ''
22
- child.stdout.on('data', (d) => (out += d.toString('utf-8')))
23
- child.stderr.on('data', (d) => (out += d.toString('utf-8')))
24
- setTimeout(() => child.kill('SIGTERM'), 8000)
25
- child.on('close', () => {
26
- const count = (seq: string): number => out.split(seq).length - 1
27
- const on = count('\u001b[?1000h')
28
- const altOff = count('\u001b[?1007l')
29
- const altOn = count('\u001b[?1007h')
30
- console.log('MOUSE_CHURN 1000h=' + on + ' 1007l=' + altOff + ' 1007h=' + altOn)
31
- const ok = on === 1 && altOff === 1 && altOn === 0
32
- console.log(ok ? 'MOUSE_CHURN_OK' : 'MOUSE_CHURN_FAIL')
33
- process.exit(ok ? 0 : 1)
34
- })
@@ -1,117 +0,0 @@
1
- // Qwen model-switching self-smoke: launch a REAL isolated QwenBrowser, list
2
- // the model catalogue, then SWITCH to several models and ask a question with
3
- // each — the operator explicitly asked that model switching be exercised.
4
- //
5
- // Isolation mirrors qwen-smoke.mts: a throwaway HOME + a read-only COPY of the
6
- // real qwen-profile (the live profile is never opened).
7
- //
8
- // Usage: npx tsx scripts/qwen-models-smoke.mts [--headed] [--all]
9
-
10
- import fs from 'fs'
11
- import path from 'path'
12
- import os from 'os'
13
-
14
- const HEADED = process.argv.includes('--headed')
15
- const ALL = process.argv.includes('--all')
16
- const REAL_HOME = os.homedir()
17
- const PROFILE_SRC = path.join(REAL_HOME, '.zames', 'qwen-profile')
18
-
19
- if (!process.env.ZAMES_QWEN_MODELS_HOME) {
20
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zames-qwen-models-'))
21
- process.env.HOME = tmp
22
- process.env.ZAMES_QWEN_MODELS_HOME = tmp
23
- fs.mkdirSync(path.join(tmp, '.zames'), { recursive: true })
24
- fs.mkdirSync(path.join(tmp, '.cache'), { recursive: true })
25
- const realCfg = path.join(REAL_HOME, '.zames', 'config.json')
26
- if (fs.existsSync(realCfg)) {
27
- fs.copyFileSync(realCfg, path.join(tmp, '.zames', 'config.json'))
28
- }
29
- const realPw = path.join(REAL_HOME, '.cache', 'ms-playwright')
30
- if (fs.existsSync(realPw)) {
31
- fs.symlinkSync(realPw, path.join(tmp, '.cache', 'ms-playwright'))
32
- }
33
- if (fs.existsSync(PROFILE_SRC)) {
34
- fs.cpSync(PROFILE_SRC, path.join(tmp, '.zames', 'qwen-profile'), {
35
- recursive: true,
36
- filter: (src) => !/^Singleton/.test(path.basename(src)),
37
- })
38
- }
39
- }
40
-
41
- const { QwenBrowser } = await import('../src/qwen-browser.ts')
42
- const { loadConfig } = await import('../src/config.ts')
43
- const cfg = loadConfig()
44
-
45
- const results: Array<{ name: string; ok: boolean; note?: string }> = []
46
- const record = (name: string, ok: boolean, note?: string): void => {
47
- results.push({ name, ok, note })
48
- console.log((ok ? 'PASS ' : 'FAIL ') + name + (note ? ' — ' + note : ''))
49
- }
50
-
51
- const browser = new QwenBrowser({
52
- headless: !HEADED,
53
- debug: false,
54
- channel: cfg.browserChannel,
55
- auth: cfg.browser.auth,
56
- locale: 'ru',
57
- minSendIntervalMs: 0,
58
- answerTimeoutMs: 180000,
59
- webSearch: false,
60
- deepThinking: false,
61
- })
62
- browser.onNotice = (t: string) => console.log(' [notice] ' + t)
63
-
64
- async function main(): Promise<void> {
65
- await browser.launch()
66
- const timedOut = await Promise.race([
67
- browser.waitForLogin().then(() => false),
68
- new Promise<boolean>((r) => setTimeout(() => r(true), 60000)),
69
- ])
70
- if (timedOut) {
71
- record('login', false, 'timed out')
72
- await browser.close().catch(() => {})
73
- process.exit(2)
74
- }
75
- record('login', true)
76
-
77
- const models = await browser.listModels()
78
- record(
79
- 'listModels',
80
- models.length > 0,
81
- 'n=' + models.length + ' ' + models.map((m) => m.id).join(','),
82
- )
83
-
84
- // Switch through a few models and ask with each. Prefer distinct vendors so
85
- // the dropdown selection really changes the backend.
86
- const wanted = ALL ? models : models.slice(0, 3)
87
- for (const m of wanted) {
88
- try {
89
- await browser.newChat()
90
- browser.model = m.id
91
- const ans = await browser.ask('Reply with exactly: OK', { agent: true })
92
- const ok = /OK/i.test(ans)
93
- record('model:' + m.id, ok, ok ? '' : 'got: ' + ans.slice(0, 60))
94
- } catch (e) {
95
- record('model:' + m.id, false, (e as Error).message)
96
- }
97
- }
98
-
99
- await browser.close().catch(() => {})
100
- const failed = results.filter((r) => !r.ok)
101
- console.log(
102
- '\nqwen-models-smoke: ' +
103
- (results.length - failed.length) +
104
- '/' +
105
- results.length +
106
- ' passed',
107
- )
108
- if (!process.env.ZAMES_QWEN_MODELS_KEEP) {
109
- fs.rmSync(process.env.ZAMES_QWEN_MODELS_HOME as string, {
110
- recursive: true,
111
- force: true,
112
- })
113
- }
114
- process.exit(failed.length ? 1 : 0)
115
- }
116
-
117
- void main()
@@ -1,221 +0,0 @@
1
- // Qwen (chat.qwen.ai) self-smoke: a REAL isolated QwenBrowser instance against
2
- // the live site, to catch integration regressions (login, send, answer) that
3
- // unit tests cannot.
4
- //
5
- // Isolation: HOME is pointed at a throwaway dir BEFORE importing any project
6
- // module (they compute ~/.zames paths at load). The real Qwen profile
7
- // (~/.zames/qwen-profile) is COPIED read-only into the temp HOME so the
8
- // browser starts already signed in; the live profile is NEVER opened. The
9
- // Playwright browser cache is symlinked read-only. Nothing in the operator's
10
- // ~/.zames is written.
11
- //
12
- // Usage: npx tsx scripts/qwen-smoke.mts [--headed] [--no-reuse-profile]
13
- // Exit code 0 = all scenarios passed; 2 = login failed (could not start).
14
-
15
- import fs from 'fs'
16
- import path from 'path'
17
- import os from 'os'
18
-
19
- const HEADED = process.argv.includes('--headed')
20
- const REUSE_PROFILE = !process.argv.includes('--no-reuse-profile')
21
- const REAL_HOME = os.homedir()
22
- const PROFILE_SRC = path.join(REAL_HOME, '.zames', 'qwen-profile')
23
-
24
- if (!process.env.ZAMES_QWEN_SMOKE_HOME) {
25
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zames-qwen-smoke-'))
26
- process.env.HOME = tmp
27
- process.env.ZAMES_QWEN_SMOKE_HOME = tmp
28
- fs.mkdirSync(path.join(tmp, '.zames'), { recursive: true })
29
- fs.mkdirSync(path.join(tmp, '.cache'), { recursive: true })
30
- const realCfg = path.join(REAL_HOME, '.zames', 'config.json')
31
- if (fs.existsSync(realCfg)) {
32
- fs.copyFileSync(realCfg, path.join(tmp, '.zames', 'config.json'))
33
- }
34
- const realPw = path.join(REAL_HOME, '.cache', 'ms-playwright')
35
- if (fs.existsSync(realPw)) {
36
- fs.symlinkSync(realPw, path.join(tmp, '.cache', 'ms-playwright'))
37
- }
38
- if (REUSE_PROFILE && fs.existsSync(PROFILE_SRC)) {
39
- fs.cpSync(PROFILE_SRC, path.join(tmp, '.zames', 'qwen-profile'), {
40
- recursive: true,
41
- filter: (src) => !/^Singleton/.test(path.basename(src)),
42
- })
43
- }
44
- }
45
- const SMOKE_HOME = process.env.ZAMES_QWEN_SMOKE_HOME as string
46
-
47
- // --- project modules (dynamic: they read HOME at import) --------------------
48
- const { QwenBrowser } = await import('../src/qwen-browser.ts')
49
- const { loadConfig } = await import('../src/config.ts')
50
-
51
- const cfg = loadConfig()
52
-
53
- interface SmokeResult {
54
- name: string
55
- ok: boolean
56
- note?: string
57
- }
58
- const results: SmokeResult[] = []
59
- let loginFailed = false
60
- const record = (name: string, ok: boolean, note?: string): void => {
61
- results.push({ name, ok, note })
62
- console.log((ok ? 'PASS ' : 'FAIL ') + name + (note ? ' — ' + note : ''))
63
- }
64
-
65
- const browser = new QwenBrowser({
66
- headless: !HEADED,
67
- debug: process.env.ZAMES_QWEN_SMOKE_DEBUG === '1',
68
- channel: cfg.browserChannel,
69
- auth: cfg.browser.auth,
70
- locale: 'ru',
71
- minSendIntervalMs: 0,
72
- answerTimeoutMs: 180000,
73
- webSearch: false,
74
- deepThinking: false,
75
- })
76
- browser.onNotice = (t: string) => console.log(' [notice] ' + t)
77
-
78
- async function main() {
79
- console.log(
80
- 'qwen-smoke: HOME=' +
81
- SMOKE_HOME +
82
- ' headed=' +
83
- HEADED +
84
- ' reuse-profile=' +
85
- REUSE_PROFILE,
86
- )
87
- await browser.launch()
88
-
89
- // Bound the login wait so a cold/expired session fails fast with a clear
90
- // hint instead of hanging until the caller's tool timeout.
91
- const loginPromise = browser.waitForLogin()
92
- const timedOut = await Promise.race([
93
- loginPromise.then(() => false),
94
- new Promise<boolean>((r) => setTimeout(() => r(true), 60000)),
95
- ])
96
- if (timedOut) {
97
- loginFailed = true
98
- record('login', false, 'timed out after 60s')
99
- } else {
100
- const logged = await browser.isLoggedIn().catch(() => false)
101
- record('login', logged, logged ? '' : 'not signed in (guest)')
102
- if (!logged) loginFailed = true
103
- }
104
-
105
- if (!loginFailed) {
106
- // 1) new chat + a SIMPLE ask that needs no tools: the model must answer.
107
- try {
108
- await browser.newChat()
109
- const id = await browser.getCurrentChatId()
110
- record('newChat', true, 'id=' + (id || '(none)'))
111
- } catch (e) {
112
- record('newChat', false, (e as Error).message)
113
- }
114
-
115
- try {
116
- const ans = await browser.ask('Reply with exactly: PONG', {
117
- agent: true,
118
- })
119
- const ok = /PONG/i.test(ans)
120
- record('ask:simple', ok, ok ? '' : 'got: ' + ans.slice(0, 80))
121
- } catch (e) {
122
- record('ask:simple', false, (e as Error).message)
123
- }
124
-
125
- // 2) a second send right after the first: guards the send-verify path (the
126
- // "text entered but send button not pressed" regression).
127
- try {
128
- const ans = await browser.ask('Reply with exactly: PONG2', {
129
- agent: true,
130
- })
131
- const ok = /PONG2/i.test(ans)
132
- record('ask:second-send', ok, ok ? '' : 'got: ' + ans.slice(0, 80))
133
- } catch (e) {
134
- record('ask:second-send', false, (e as Error).message)
135
- }
136
-
137
- // 3) model catalogue + token usage (best-effort, non-fatal).
138
- try {
139
- const models = await browser.listModels()
140
- record('listModels', models.length > 0, 'n=' + models.length)
141
- } catch (e) {
142
- record('listModels', false, (e as Error).message)
143
- }
144
-
145
- const usage = browser.getLastTokenUsage()
146
- record('tokenUsage', usage !== null, 'usage=' + String(usage))
147
-
148
- // 4) tool-call emission: the agent-loop depends on the model returning a
149
- // parseable JSON tool call. Ask for one explicitly and check the PARSER
150
- // (not the model's prose).
151
- try {
152
- const { parseToolCall } = await import('../src/answer-parse.ts')
153
- const ans = await browser.ask(
154
- 'Call the Read tool for the file package.json. Reply with EXACTLY one ' +
155
- 'JSON tool-call object and nothing else: ' +
156
- '{"tool":"Read","args":{"path":"..."}}',
157
- { agent: true },
158
- )
159
- const parsed = parseToolCall(ans)
160
- const call = Array.isArray(parsed) ? parsed[0] : parsed
161
- const ok = !!call && call.tool === 'Read'
162
- record('toolCall', ok, ok ? '' : 'got: ' + ans.slice(0, 100))
163
- } catch (e) {
164
- record('toolCall', false, (e as Error).message)
165
- }
166
-
167
- // History restore: after the sends above the chat must have messages.
168
- try {
169
- const id = await browser.getCurrentChatId()
170
- const msgs = id ? await browser.fetchChatMessages(id) : []
171
- record(
172
- 'history',
173
- msgs.length > 0,
174
- 'id=' + (id || '-') + ' msgs=' + msgs.length,
175
- )
176
- } catch (e) {
177
- record('history', false, (e as Error).message)
178
- }
179
-
180
- // Attachments: a SIGNED-IN session uploads a file; a GUEST cannot, so a
181
- // guest failure is reported as a SKIP (not a failure) — the guest limit is
182
- // a site restriction, not a regression.
183
- try {
184
- const tmpFile = path.join(SMOKE_HOME, 'zames-smoke-note.txt')
185
- fs.writeFileSync(tmpFile, 'The secret word is BANANA')
186
- const failed = await browser._attachFiles([
187
- { path: tmpFile, name: 'zames-smoke-note.txt', mime: 'text/plain' },
188
- ])
189
- const attached = failed.length === 0
190
- const isGuest = !(await browser.isLoggedIn().catch(() => false))
191
- if (attached) record('attachments', true, '')
192
- else if (isGuest)
193
- record('attachments', true, 'skipped (guest cannot upload)')
194
- else record('attachments', false, JSON.stringify(failed))
195
- } catch (e) {
196
- record('attachments', false, (e as Error).message)
197
- }
198
- }
199
-
200
- await browser.close().catch(() => {})
201
-
202
- const failed = results.filter((r) => !r.ok)
203
- console.log(
204
- '\nqwen-smoke: ' +
205
- (results.length - failed.length) +
206
- '/' +
207
- results.length +
208
- ' passed',
209
- )
210
- if (!process.env.ZAMES_QWEN_SMOKE_KEEP) {
211
- fs.rmSync(SMOKE_HOME, { recursive: true, force: true })
212
- } else {
213
- console.log('kept HOME: ' + SMOKE_HOME)
214
- }
215
- process.exit(loginFailed ? 2 : failed.length ? 1 : 0)
216
- }
217
-
218
- main().catch((e) => {
219
- console.error('qwen-smoke crashed:', (e as Error).message)
220
- process.exit(1)
221
- })
@@ -1,199 +0,0 @@
1
- import { spawnSync } from 'node:child_process'
2
- import fs from 'node:fs'
3
- import path from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
- import {
6
- parseConventionalCommit,
7
- groupCommits,
8
- renderChangelogDraft,
9
- } from '../src/changelog.js'
10
-
11
- // One-command release prep (BACKLOG G4). The manual sequence — bump
12
- // package.json, write a dated CHANGELOG section, commit `chore: release X.Y.Z`,
13
- // tag — is easy to get wrong, so this script does the deterministic parts and
14
- // STOPS before the push. Pushing the branch and the tag is deliberately a
15
- // separate, explicit step: the tag triggers the npm publish workflow, and that
16
- // must never happen by accident.
17
- //
18
- // Usage:
19
- // npm run release patch # bug fixes
20
- // npm run release minor # new features
21
- // npm run release major # breaking changes
22
- // npm run release patch --no-tests # skip the local test run (CI still runs)
23
- // npm run release patch --dry-run # print the plan, change nothing
24
- //
25
- // Run via tsx so it can reuse src/changelog.ts (the conventional-commit
26
- // parser) — a plain .mjs cannot import the TypeScript sources. The other
27
- // helper scripts are .mts for the same reason; postinstall stays .mjs because
28
- // it runs before tsx is guaranteed to be installed.
29
-
30
- const NL = String.fromCharCode(10)
31
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
32
-
33
- function git(args: string[], allowFail = false): string {
34
- const r = spawnSync('git', args, { cwd: root, encoding: 'utf-8' })
35
- if (r.status !== 0 && !allowFail) {
36
- console.error('git ' + args.join(' ') + ' failed:' + NL + (r.stderr || ''))
37
- process.exit(1)
38
- }
39
- return (r.stdout || '').toString()
40
- }
41
-
42
- function fail(msg: string): never {
43
- console.error('release: ' + msg)
44
- process.exit(1)
45
- }
46
-
47
- function run(cmd: string, args: string[]): void {
48
- const r = spawnSync(cmd, args, {
49
- cwd: root,
50
- stdio: 'inherit',
51
- shell: process.platform === 'win32',
52
- })
53
- if (r.status !== 0) fail(cmd + ' ' + args.join(' ') + ' exited ' + r.status)
54
- }
55
-
56
- const argv = process.argv.slice(2)
57
- const flags = new Set(argv.filter((a) => a.startsWith('--')))
58
- const bump = argv.find((a) => !a.startsWith('--'))
59
- const dryRun = flags.has('--dry-run')
60
- const skipTests = flags.has('--no-tests')
61
-
62
- if (bump !== 'patch' && bump !== 'minor' && bump !== 'major') {
63
- fail('usage: npm run release patch|minor|major [--no-tests] [--dry-run]')
64
- }
65
-
66
- // 1. Clean tree: a release commit must contain ONLY the version bump and the
67
- // changelog section, never half-finished work.
68
- const dirty = git(['status', '--porcelain']).trim()
69
- if (dirty) {
70
- fail('working tree is not clean:' + NL + dirty)
71
- }
72
-
73
- // 2. Gates. The git hooks run these on commit/push too, but a release should
74
- // fail EARLY, before package.json/CHANGELOG are touched.
75
- if (!skipTests) {
76
- run('npm', ['run', 'typecheck'])
77
- run('npm', ['test'])
78
- }
79
-
80
- // 3. Compute the next version from package.json.
81
- const pkgPath = path.join(root, 'package.json')
82
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as {
83
- version: string
84
- }
85
- const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(pkg.version)
86
- if (!m) fail('package.json version is not a plain x.y.z: ' + pkg.version)
87
- let [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])]
88
- if (bump === 'major') {
89
- major += 1
90
- minor = 0
91
- patch = 0
92
- } else if (bump === 'minor') {
93
- minor += 1
94
- patch = 0
95
- } else {
96
- patch += 1
97
- }
98
- const next = major + '.' + minor + '.' + patch
99
- const tag = 'v' + next
100
-
101
- if (git(['tag', '--list', tag]).trim()) {
102
- fail('tag ' + tag + ' already exists')
103
- }
104
-
105
- // 4. Build the dated CHANGELOG section from conventional commits since the
106
- // last tag. English commit subjects become the section body; the operator can
107
- // still reword the section afterwards.
108
- const latestTag = git(['describe', '--tags', '--abbrev=0'], true).trim()
109
- const ref =
110
- latestTag ||
111
- git(['rev-list', '--max-parents=0', 'HEAD']).trim().split(NL).pop() ||
112
- 'HEAD'
113
- const subjects = git([
114
- 'log',
115
- '--no-merges',
116
- '--pretty=format:%s',
117
- ref + '..HEAD',
118
- ])
119
- .split(NL)
120
- .map((s) => s.trim())
121
- .filter(Boolean)
122
- const parsed = subjects
123
- .map(parseConventionalCommit)
124
- .filter((c): c is NonNullable<typeof c> => c !== null)
125
- let body = renderChangelogDraft(groupCommits(parsed))
126
-
127
- // The conventional draft skips chore/docs/test/etc, so a release whose only
128
- // change is a docs fix (a README tweak users WILL see on the npm page) would
129
- // otherwise collapse to a useless "Internal improvements." placeholder. Fall
130
- // back to the raw commit subjects under Changed instead of that stub.
131
- if (!body) {
132
- const fallback = subjects.filter((s) => !/^chore(:|\(release\))/i.test(s))
133
- if (fallback.length) {
134
- body = ['## Changed', ''].concat(fallback.map((s) => '- ' + s)).join(NL)
135
- }
136
- }
137
-
138
- // CHANGELOG groups are `### Added` (Keep a Changelog), the draft renders
139
- // `## Added` — normalize here.
140
- const sectionBody = body
141
- .split(NL)
142
- .map((line) => (line.startsWith('## ') ? '#' + line : line))
143
- .join(NL)
144
- const date = new Date().toISOString().slice(0, 10)
145
- const section =
146
- '## [' +
147
- next +
148
- '] - ' +
149
- date +
150
- NL +
151
- NL +
152
- (sectionBody || '### Changed' + NL + NL + '- Internal improvements.') +
153
- NL
154
-
155
- if (dryRun) {
156
- console.log('release(dry-run): ' + pkg.version + ' -> ' + next)
157
- console.log('tag: ' + tag)
158
- console.log('since: ' + ref)
159
- console.log('CHANGELOG section:' + NL + section)
160
- process.exit(0)
161
- }
162
-
163
- // 5. Insert the version section in place of the Unreleased BODY. The
164
- // Unreleased body is auto-synced from commits (see changelog:sync), so it is
165
- // the same content as the new section — leaving it in place duplicated every
166
- // entry (the real "### Fixed" block twice). Replace the whole body up to the
167
- // next version heading with the new section.
168
- const changelogPath = path.join(root, 'CHANGELOG.md')
169
- const changelog = fs.readFileSync(changelogPath, 'utf-8')
170
- const anchor = '## [Unreleased]'
171
- const idx = changelog.indexOf(anchor)
172
- if (idx < 0) fail('CHANGELOG.md has no "' + anchor + '" heading')
173
- const afterAnchor = idx + anchor.length
174
- const rest = changelog.slice(afterAnchor)
175
- const nextHeading = rest.search(/\n## \[/)
176
- const tail = nextHeading < 0 ? NL : rest.slice(nextHeading)
177
- const updated = changelog.slice(0, afterAnchor) + NL + NL + section + tail
178
- fs.writeFileSync(changelogPath, updated)
179
-
180
- // 6. Bump package.json, preserving its exact formatting (single-line replace
181
- // of the version value only).
182
- const pkgText = fs.readFileSync(pkgPath, 'utf-8')
183
- fs.writeFileSync(
184
- pkgPath,
185
- pkgText.replace(
186
- '"version": "' + pkg.version + '"',
187
- '"version": "' + next + '"',
188
- ),
189
- )
190
-
191
- // 7. Commit + tag. The push is intentionally NOT done here.
192
- git(['add', 'package.json', 'CHANGELOG.md'])
193
- git(['commit', '-m', 'chore: release ' + next])
194
- git(['tag', tag])
195
-
196
- console.log(NL + 'release: committed and tagged ' + tag)
197
- console.log('Next (explicit) step — publish via CI:')
198
- console.log(' git push')
199
- console.log(' git push origin ' + tag)