bingocode 1.0.28 → 1.0.30

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 (53) hide show
  1. package/adapters/common/__tests__/chat-queue.test.ts +61 -0
  2. package/adapters/common/__tests__/format.test.ts +148 -0
  3. package/adapters/common/__tests__/http-client.test.ts +105 -0
  4. package/adapters/common/__tests__/message-buffer.test.ts +84 -0
  5. package/adapters/common/__tests__/message-dedup.test.ts +57 -0
  6. package/adapters/common/__tests__/session-store.test.ts +62 -0
  7. package/adapters/common/__tests__/ws-bridge.test.ts +177 -0
  8. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
  9. package/adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
  10. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
  11. package/adapters/feishu/__tests__/card-errors.test.ts +194 -0
  12. package/adapters/feishu/__tests__/cardkit.test.ts +295 -0
  13. package/adapters/feishu/__tests__/extract-payload.test.ts +77 -0
  14. package/adapters/feishu/__tests__/feishu.test.ts +907 -0
  15. package/adapters/feishu/__tests__/flush-controller.test.ts +290 -0
  16. package/adapters/feishu/__tests__/markdown-style.test.ts +353 -0
  17. package/adapters/feishu/__tests__/media.test.ts +120 -0
  18. package/adapters/feishu/__tests__/streaming-card.test.ts +914 -0
  19. package/adapters/telegram/__tests__/media.test.ts +86 -0
  20. package/adapters/telegram/__tests__/telegram.test.ts +115 -0
  21. package/adapters/tsconfig.json +18 -0
  22. package/bunfig.toml +1 -0
  23. package/package.json +1 -1
  24. package/preload.ts +30 -0
  25. package/scripts/count-app-loc.ts +256 -0
  26. package/scripts/release.ts +130 -0
  27. package/src/server/__tests__/conversation-service.test.ts +173 -0
  28. package/src/server/__tests__/conversations.test.ts +458 -0
  29. package/src/server/__tests__/cron-scheduler.test.ts +575 -0
  30. package/src/server/__tests__/e2e/business-flow.test.ts +841 -0
  31. package/src/server/__tests__/e2e/full-flow.test.ts +357 -0
  32. package/src/server/__tests__/fixtures/mock-sdk-cli.ts +123 -0
  33. package/src/server/__tests__/haha-oauth-api.test.ts +146 -0
  34. package/src/server/__tests__/haha-oauth-service.test.ts +185 -0
  35. package/src/server/__tests__/providers-real.test.ts +244 -0
  36. package/src/server/__tests__/providers.test.ts +579 -0
  37. package/src/server/__tests__/proxy-streaming.test.ts +317 -0
  38. package/src/server/__tests__/proxy-transform.test.ts +469 -0
  39. package/src/server/__tests__/real-llm-test.ts +526 -0
  40. package/src/server/__tests__/scheduled-tasks.test.ts +371 -0
  41. package/src/server/__tests__/sessions.test.ts +786 -0
  42. package/src/server/__tests__/settings.test.ts +376 -0
  43. package/src/server/__tests__/skills.test.ts +125 -0
  44. package/src/server/__tests__/tasks.test.ts +171 -0
  45. package/src/server/__tests__/team-watcher.test.ts +400 -0
  46. package/src/server/__tests__/teams.test.ts +627 -0
  47. package/src/server/middleware/cors.test.ts +27 -0
  48. package/src/utils/__tests__/cronFrequency.test.ts +153 -0
  49. package/src/utils/__tests__/cronTasks.test.ts +204 -0
  50. package/src/utils/computerUse/permissions.test.ts +44 -0
  51. package/stubs/ant-claude-for-chrome-mcp.ts +24 -0
  52. package/stubs/color-diff-napi.ts +45 -0
  53. package/tsconfig.json +24 -0
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import { TelegramMediaService } from '../media.js'
6
+ import { AttachmentStore } from '../../common/attachment/attachment-store.js'
7
+
8
+ let tmpRoot: string
9
+ let originalFetch: typeof fetch
10
+
11
+ function makeMockBot() {
12
+ const fetchMock = mock(async (url: string | URL) => {
13
+ const u = typeof url === 'string' ? url : url.toString()
14
+ expect(u).toContain('/file/botFAKE_TOKEN/photos/abc.jpg')
15
+ return new Response(Buffer.from('PHOTODATA'), {
16
+ status: 200,
17
+ headers: { 'content-type': 'image/jpeg' },
18
+ })
19
+ })
20
+ ;(globalThis as any).fetch = fetchMock
21
+ return {
22
+ token: 'FAKE_TOKEN',
23
+ api: {
24
+ getFile: mock(async (fileId: string) => ({
25
+ file_id: fileId,
26
+ file_unique_id: 'unique',
27
+ file_path: 'photos/abc.jpg',
28
+ })),
29
+ sendPhoto: mock(async () => ({ message_id: 1 })),
30
+ sendDocument: mock(async () => ({ message_id: 2 })),
31
+ },
32
+ fetchMock,
33
+ }
34
+ }
35
+
36
+ beforeEach(async () => {
37
+ originalFetch = globalThis.fetch
38
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'tg-media-test-'))
39
+ })
40
+
41
+ afterEach(async () => {
42
+ globalThis.fetch = originalFetch
43
+ await fs.rm(tmpRoot, { recursive: true, force: true })
44
+ })
45
+
46
+ describe('TelegramMediaService', () => {
47
+ it('downloadFile fetches the real URL and stores a LocalAttachment', async () => {
48
+ const bot = makeMockBot()
49
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
50
+ const svc = new TelegramMediaService(bot as any, store)
51
+ const local = await svc.downloadFile('fid_123', 'sess-1', {
52
+ fileName: 'abc.jpg',
53
+ mimeType: 'image/jpeg',
54
+ })
55
+ expect(local.kind).toBe('image')
56
+ expect(local.name).toBe('abc.jpg')
57
+ expect(local.size).toBe('PHOTODATA'.length)
58
+ expect(local.buffer.toString()).toBe('PHOTODATA')
59
+ const onDisk = await fs.readFile(local.path)
60
+ expect(onDisk.toString()).toBe('PHOTODATA')
61
+ })
62
+
63
+ it('sendPhoto calls bot.api.sendPhoto with InputFile-like payload', async () => {
64
+ const bot = makeMockBot()
65
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
66
+ const svc = new TelegramMediaService(bot as any, store)
67
+ await svc.sendPhoto(42, Buffer.from('IMG'), 'caption text')
68
+ expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1)
69
+ const args = (bot.api.sendPhoto as any).mock.calls[0]
70
+ expect(args[0]).toBe(42)
71
+ // grammY InputFile wraps the buffer; just verify it's an object.
72
+ expect(args[1]).toBeDefined()
73
+ expect(args[2]?.caption).toBe('caption text')
74
+ })
75
+
76
+ it('sendDocument calls bot.api.sendDocument', async () => {
77
+ const bot = makeMockBot()
78
+ const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
79
+ const svc = new TelegramMediaService(bot as any, store)
80
+ await svc.sendDocument(42, Buffer.from('DOC'), 'spec.pdf')
81
+ expect(bot.api.sendDocument).toHaveBeenCalledTimes(1)
82
+ const args = (bot.api.sendDocument as any).mock.calls[0]
83
+ expect(args[0]).toBe(42)
84
+ expect(args[1]).toBeDefined()
85
+ })
86
+ })
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { splitMessage, formatPermissionRequest, truncateInput, escapeMarkdownV2 } from '../../common/format.js'
3
+
4
+ /**
5
+ * Telegram Adapter 翻译逻辑测试
6
+ *
7
+ * 由于 grammy Bot 需要实际 Token 才能初始化,
8
+ * 这里测试的是不依赖 Bot 实例的核心翻译逻辑。
9
+ */
10
+
11
+ describe('Telegram message formatting', () => {
12
+ describe('long message splitting', () => {
13
+ it('splits messages at Telegram 4096 char limit', () => {
14
+ const longText = 'a'.repeat(8000)
15
+ const chunks = splitMessage(longText, 4000)
16
+ expect(chunks.length).toBe(2)
17
+ expect(chunks[0]!.length).toBeLessThanOrEqual(4000)
18
+ expect(chunks[1]!.length).toBeLessThanOrEqual(4000)
19
+ })
20
+
21
+ it('keeps short messages as single chunk', () => {
22
+ const chunks = splitMessage('Hello World', 4000)
23
+ expect(chunks).toEqual(['Hello World'])
24
+ })
25
+
26
+ it('splits at paragraph boundary when possible', () => {
27
+ const text = 'A'.repeat(2000) + '\n\n' + 'B'.repeat(2000)
28
+ const chunks = splitMessage(text, 3000)
29
+ expect(chunks.length).toBe(2)
30
+ })
31
+ })
32
+
33
+ describe('permission request formatting', () => {
34
+ it('formats Bash command request', () => {
35
+ const result = formatPermissionRequest('Bash', { command: 'npm test' }, 'abcde')
36
+ expect(result).toContain('🔐')
37
+ expect(result).toContain('Bash')
38
+ expect(result).toContain('npm test')
39
+ expect(result).toContain('abcde')
40
+ })
41
+
42
+ it('formats Write file request', () => {
43
+ const result = formatPermissionRequest(
44
+ 'Write',
45
+ { file_path: '/src/index.ts', content: 'console.log("hello")' },
46
+ 'fghij',
47
+ )
48
+ expect(result).toContain('Write')
49
+ expect(result).toContain('index.ts')
50
+ expect(result).toContain('fghij')
51
+ })
52
+
53
+ it('truncates long input in permission request', () => {
54
+ const longInput = { command: 'x'.repeat(500) }
55
+ const result = formatPermissionRequest('Bash', longInput, 'xxxxx')
56
+ expect(result.length).toBeLessThan(600)
57
+ })
58
+ })
59
+
60
+ describe('callback_data parsing', () => {
61
+ it('parses permit:requestId:yes format', () => {
62
+ const data = 'permit:abcde:yes'
63
+ const parts = data.split(':')
64
+ expect(parts[0]).toBe('permit')
65
+ expect(parts[1]).toBe('abcde')
66
+ expect(parts[2]).toBe('yes')
67
+ })
68
+
69
+ it('parses permit:requestId:no format', () => {
70
+ const data = 'permit:abcde:no'
71
+ const parts = data.split(':')
72
+ expect(parts[2]).toBe('no')
73
+ })
74
+
75
+ it('ignores non-permit callbacks', () => {
76
+ const data = 'other:action'
77
+ expect(data.startsWith('permit:')).toBe(false)
78
+ })
79
+ })
80
+
81
+ describe('MarkdownV2 escaping', () => {
82
+ it('escapes underscores', () => {
83
+ expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
84
+ })
85
+
86
+ it('escapes multiple special chars', () => {
87
+ const result = escapeMarkdownV2('file.ts (line 42)')
88
+ expect(result).toBe('file\\.ts \\(line 42\\)')
89
+ })
90
+
91
+ it('handles code blocks safely', () => {
92
+ const result = escapeMarkdownV2('`code`')
93
+ expect(result).toBe('\\`code\\`')
94
+ })
95
+ })
96
+
97
+ describe('whitelist logic', () => {
98
+ it('empty allowedUsers means allow all', () => {
99
+ const allowedUsers: number[] = []
100
+ const isAllowed = (userId: number) =>
101
+ allowedUsers.length === 0 || allowedUsers.includes(userId)
102
+ expect(isAllowed(12345)).toBe(true)
103
+ expect(isAllowed(99999)).toBe(true)
104
+ })
105
+
106
+ it('non-empty allowedUsers filters correctly', () => {
107
+ const allowedUsers = [111, 222]
108
+ const isAllowed = (userId: number) =>
109
+ allowedUsers.length === 0 || allowedUsers.includes(userId)
110
+ expect(isAllowed(111)).toBe(true)
111
+ expect(isAllowed(222)).toBe(true)
112
+ expect(isAllowed(333)).toBe(false)
113
+ })
114
+ })
115
+ })
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "types": ["bun-types"],
10
+ "outDir": "dist",
11
+ "rootDir": ".",
12
+ "paths": {
13
+ "@server/*": ["../src/server/*"]
14
+ }
15
+ },
16
+ "include": ["**/*.ts"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }
package/bunfig.toml ADDED
@@ -0,0 +1 @@
1
+ preload = ["./preload.ts"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bingocode",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude": "bin/claude-win.cjs",
package/preload.ts ADDED
@@ -0,0 +1,30 @@
1
+ // 优先用环境变量,其次从 package.json 读取真实版本号
2
+ import { readFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ let _pkgVersion = '1.0.0'
5
+ try {
6
+ const _pkgPath = join(import.meta.dir, 'package.json')
7
+ const _pkg = JSON.parse(readFileSync(_pkgPath, 'utf-8')) as { version?: string }
8
+ if (_pkg.version) _pkgVersion = _pkg.version
9
+ } catch { /* ignore */ }
10
+ const version = process.env.CLAUDE_CODE_LOCAL_VERSION ?? _pkgVersion;
11
+ const packageUrl = process.env.CLAUDE_CODE_LOCAL_PACKAGE_URL ?? 'claude-code-local';
12
+ const buildTime = process.env.CLAUDE_CODE_LOCAL_BUILD_TIME ?? new Date().toISOString();
13
+
14
+ process.env.CLAUDE_CODE_LOCAL_SKIP_REMOTE_PREFETCH ??= '1';
15
+
16
+ Object.assign(globalThis, {
17
+ MACRO: {
18
+ VERSION: version,
19
+ PACKAGE_URL: packageUrl,
20
+ NATIVE_PACKAGE_URL: packageUrl,
21
+ BUILD_TIME: buildTime,
22
+ FEEDBACK_CHANNEL: 'local',
23
+ VERSION_CHANGELOG: '',
24
+ ISSUES_EXPLAINER: '',
25
+ },
26
+ });
27
+ // Switch to the current workspace
28
+ if (process.env.CALLER_DIR) {
29
+ process.chdir(process.env.CALLER_DIR);
30
+ }
@@ -0,0 +1,256 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { extname, join, relative, sep } from 'node:path'
3
+
4
+ type Bucket = {
5
+ files: number
6
+ lines: number
7
+ nonBlankLines: number
8
+ }
9
+
10
+ type FileStat = Bucket & {
11
+ path: string
12
+ extension: string
13
+ }
14
+
15
+ const root = process.cwd()
16
+
17
+ const targetRoots = ['adapters', 'desktop', 'runtime', 'src/server']
18
+
19
+ const codeExtensions = new Set([
20
+ '.css',
21
+ '.cjs',
22
+ '.html',
23
+ '.js',
24
+ '.jsx',
25
+ '.mjs',
26
+ '.nsh',
27
+ '.ps1',
28
+ '.py',
29
+ '.rs',
30
+ '.sh',
31
+ '.ts',
32
+ '.tsx',
33
+ ])
34
+
35
+ const excludedDirectoryNames = new Set([
36
+ '.cache',
37
+ '.git',
38
+ '.next',
39
+ '.nuxt',
40
+ '.omx',
41
+ '.parcel-cache',
42
+ '.svelte-kit',
43
+ '.tauri',
44
+ '.turbo',
45
+ '.vite',
46
+ '.vite-temp',
47
+ '__pycache__',
48
+ 'build',
49
+ 'build-artifacts',
50
+ 'coverage',
51
+ 'dist',
52
+ 'node_modules',
53
+ 'out',
54
+ 'target',
55
+ ])
56
+
57
+ const excludedRelativePaths = new Set([
58
+ 'desktop/src-tauri/binaries',
59
+ 'desktop/src-tauri/icons',
60
+ ])
61
+
62
+ const files: FileStat[] = []
63
+
64
+ function emptyBucket(): Bucket {
65
+ return {
66
+ files: 0,
67
+ lines: 0,
68
+ nonBlankLines: 0,
69
+ }
70
+ }
71
+
72
+ function addToBucket(bucket: Bucket, file: FileStat) {
73
+ bucket.files += file.files
74
+ bucket.lines += file.lines
75
+ bucket.nonBlankLines += file.nonBlankLines
76
+ }
77
+
78
+ function shouldSkipDirectory(path: string) {
79
+ const name = path.split(sep).at(-1)
80
+ const normalized = relative(root, path).split(sep).join('/')
81
+
82
+ return (
83
+ Boolean(name && excludedDirectoryNames.has(name)) ||
84
+ excludedRelativePaths.has(normalized)
85
+ )
86
+ }
87
+
88
+ function countFile(path: string): FileStat | null {
89
+ const extension = extname(path)
90
+
91
+ if (!codeExtensions.has(extension)) {
92
+ return null
93
+ }
94
+
95
+ const content = readFileSync(path, 'utf8')
96
+ const newlineCount = content.match(/\r\n|\r|\n/g)?.length ?? 0
97
+ const lines =
98
+ content.length === 0 || content.endsWith('\n') || content.endsWith('\r')
99
+ ? newlineCount
100
+ : newlineCount + 1
101
+ const nonBlankLines = content
102
+ .split(/\r\n|\r|\n/)
103
+ .filter((line) => line.trim().length > 0).length
104
+
105
+ return {
106
+ files: 1,
107
+ lines,
108
+ nonBlankLines,
109
+ path: relative(root, path).split(sep).join('/'),
110
+ extension,
111
+ }
112
+ }
113
+
114
+ function walk(path: string) {
115
+ const stat = statSync(path)
116
+
117
+ if (stat.isDirectory()) {
118
+ if (shouldSkipDirectory(path)) {
119
+ return
120
+ }
121
+
122
+ for (const entry of readdirSync(path)) {
123
+ walk(join(path, entry))
124
+ }
125
+
126
+ return
127
+ }
128
+
129
+ if (!stat.isFile()) {
130
+ return
131
+ }
132
+
133
+ const result = countFile(path)
134
+ if (result) {
135
+ files.push(result)
136
+ }
137
+ }
138
+
139
+ function formatNumber(value: number) {
140
+ return value.toLocaleString('en-US')
141
+ }
142
+
143
+ function printTable(
144
+ title: string,
145
+ rows: Array<{ name: string } & Bucket>,
146
+ nameHeader = 'Group',
147
+ ) {
148
+ console.log(`\n${title}`)
149
+ console.log(`${nameHeader.padEnd(28)} ${'Files'.padStart(7)} ${'Lines'.padStart(9)} ${'Nonblank'.padStart(9)}`)
150
+ console.log(`${'-'.repeat(28)} ${'-'.repeat(7)} ${'-'.repeat(9)} ${'-'.repeat(9)}`)
151
+
152
+ for (const row of rows) {
153
+ console.log(
154
+ `${row.name.padEnd(28)} ${formatNumber(row.files).padStart(7)} ${formatNumber(row.lines).padStart(9)} ${formatNumber(row.nonBlankLines).padStart(9)}`,
155
+ )
156
+ }
157
+ }
158
+
159
+ function groupBy<T extends string>(getKey: (file: FileStat) => T) {
160
+ const result = new Map<T, Bucket>()
161
+
162
+ for (const file of files) {
163
+ const key = getKey(file)
164
+ const bucket = result.get(key) ?? emptyBucket()
165
+ addToBucket(bucket, file)
166
+ result.set(key, bucket)
167
+ }
168
+
169
+ return [...result.entries()]
170
+ .map(([name, bucket]) => ({ name, ...bucket }))
171
+ .sort((left, right) => right.lines - left.lines)
172
+ }
173
+
174
+ function areaForPath(path: string) {
175
+ if (path.startsWith('src/server/')) {
176
+ return 'server'
177
+ }
178
+
179
+ if (path.startsWith('desktop/src/')) {
180
+ return 'desktop frontend'
181
+ }
182
+
183
+ if (path.startsWith('desktop/src-tauri/')) {
184
+ return 'desktop tauri'
185
+ }
186
+
187
+ if (path.startsWith('desktop/')) {
188
+ return 'desktop support'
189
+ }
190
+
191
+ if (path.startsWith('adapters/')) {
192
+ return 'adapters'
193
+ }
194
+
195
+ if (path.startsWith('runtime/')) {
196
+ return 'runtime'
197
+ }
198
+
199
+ return 'other'
200
+ }
201
+
202
+ function purposeForPath(path: string) {
203
+ const fileName = path.split('/').at(-1) ?? ''
204
+
205
+ if (
206
+ path.includes('/__tests__/') ||
207
+ path.includes('/fixtures/') ||
208
+ fileName.startsWith('test_') ||
209
+ /\.test\.[cm]?[jt]sx?$/.test(path) ||
210
+ /\.spec\.[cm]?[jt]sx?$/.test(path)
211
+ ) {
212
+ return 'tests and fixtures'
213
+ }
214
+
215
+ return 'product source'
216
+ }
217
+
218
+ for (const targetRoot of targetRoots) {
219
+ walk(join(root, targetRoot))
220
+ }
221
+
222
+ const total = emptyBucket()
223
+ for (const file of files) {
224
+ addToBucket(total, file)
225
+ }
226
+
227
+ const sortedByPath = [...files].sort((left, right) =>
228
+ left.path.localeCompare(right.path),
229
+ )
230
+
231
+ console.log('Desktop app source line count')
232
+ console.log('')
233
+ console.log(`Targets: ${targetRoots.join(', ')}`)
234
+ console.log(`Included extensions: ${[...codeExtensions].sort().join(', ')}`)
235
+ console.log(
236
+ `Excluded directories: ${[...excludedDirectoryNames].sort().join(', ')}`,
237
+ )
238
+ console.log(`Excluded paths: ${[...excludedRelativePaths].sort().join(', ')}`)
239
+
240
+ printTable('By area', groupBy((file) => areaForPath(file.path)))
241
+ printTable('By purpose', groupBy((file) => purposeForPath(file.path)))
242
+ printTable('By top-level target', groupBy((file) => file.path.split('/')[0]))
243
+ printTable('By extension', groupBy((file) => file.extension), 'Extension')
244
+
245
+ console.log('\nTotal')
246
+ console.log(`Files: ${formatNumber(total.files)}`)
247
+ console.log(`Lines: ${formatNumber(total.lines)}`)
248
+ console.log(`Nonblank lines: ${formatNumber(total.nonBlankLines)}`)
249
+
250
+ if (process.argv.includes('--files')) {
251
+ printTable(
252
+ 'By file',
253
+ sortedByPath.map((file) => ({ name: file.path, ...file })),
254
+ 'File',
255
+ )
256
+ }
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Release script for Claude Code Haha Desktop
4
+ *
5
+ * Usage:
6
+ * bun run scripts/release.ts patch # 0.1.0 → 0.1.1
7
+ * bun run scripts/release.ts minor # 0.1.0 → 0.2.0
8
+ * bun run scripts/release.ts major # 0.1.0 → 1.0.0
9
+ * bun run scripts/release.ts 2.0.0 # explicit version
10
+ * bun run scripts/release.ts patch --dry # preview without changes
11
+ */
12
+
13
+ import { readFileSync, writeFileSync } from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ const root = path.resolve(import.meta.dir, '..')
17
+
18
+ const VERSION_FILES = [
19
+ {
20
+ path: path.join(root, 'desktop/package.json'),
21
+ update(content: string, version: string) {
22
+ return content.replace(/"version":\s*"[^"]*"/, `"version": "${version}"`)
23
+ },
24
+ },
25
+ {
26
+ path: path.join(root, 'desktop/src-tauri/tauri.conf.json'),
27
+ update(content: string, version: string) {
28
+ return content.replace(/"version":\s*"[^"]*"/, `"version": "${version}"`)
29
+ },
30
+ },
31
+ {
32
+ path: path.join(root, 'desktop/src-tauri/Cargo.toml'),
33
+ update(content: string, version: string) {
34
+ return content.replace(/^version\s*=\s*"[^"]*"/m, `version = "${version}"`)
35
+ },
36
+ },
37
+ ]
38
+
39
+ function getCurrentVersion(): string {
40
+ const tauriConf = JSON.parse(
41
+ readFileSync(path.join(root, 'desktop/src-tauri/tauri.conf.json'), 'utf-8'),
42
+ )
43
+ return tauriConf.version
44
+ }
45
+
46
+ function bumpVersion(current: string, bump: string): string {
47
+ if (/^\d+\.\d+\.\d+$/.test(bump)) {
48
+ return bump
49
+ }
50
+
51
+ const [major, minor, patch] = current.split('.').map(Number)
52
+
53
+ switch (bump) {
54
+ case 'patch':
55
+ return `${major}.${minor}.${patch + 1}`
56
+ case 'minor':
57
+ return `${major}.${minor + 1}.0`
58
+ case 'major':
59
+ return `${major + 1}.0.0`
60
+ default:
61
+ console.error(`Invalid bump type: ${bump}`)
62
+ console.error('Usage: bun run scripts/release.ts <patch|minor|major|x.y.z> [--dry]')
63
+ process.exit(1)
64
+ }
65
+ }
66
+
67
+ async function run(cmd: string[], cwd = root) {
68
+ const proc = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'pipe' })
69
+ const stdout = await new Response(proc.stdout).text()
70
+ const stderr = await new Response(proc.stderr).text()
71
+ const code = await proc.exited
72
+ if (code !== 0) {
73
+ throw new Error(`Command failed: ${cmd.join(' ')}\n${stderr || stdout}`)
74
+ }
75
+ return stdout.trim()
76
+ }
77
+
78
+ // ── Main ──────────────────────────────────────────────
79
+
80
+ const args = process.argv.slice(2)
81
+ const dryRun = args.includes('--dry')
82
+ const bumpArg = args.find((a) => a !== '--dry')
83
+
84
+ if (!bumpArg) {
85
+ console.error('Usage: bun run scripts/release.ts <patch|minor|major|x.y.z> [--dry]')
86
+ process.exit(1)
87
+ }
88
+
89
+ const current = getCurrentVersion()
90
+ const next = bumpVersion(current, bumpArg)
91
+
92
+ console.log(`\n Version: ${current} → ${next}`)
93
+ console.log(` Tag: v${next}`)
94
+ console.log(` Dry run: ${dryRun}\n`)
95
+
96
+ if (dryRun) {
97
+ console.log('Files that would be updated:')
98
+ for (const file of VERSION_FILES) {
99
+ console.log(` - ${path.relative(root, file.path)}`)
100
+ }
101
+ process.exit(0)
102
+ }
103
+
104
+ // Update version in all files
105
+ for (const file of VERSION_FILES) {
106
+ const content = readFileSync(file.path, 'utf-8')
107
+ const updated = file.update(content, next)
108
+ writeFileSync(file.path, updated)
109
+ console.log(` Updated: ${path.relative(root, file.path)}`)
110
+ }
111
+
112
+ // Regenerate Cargo.lock
113
+ console.log('\n Updating Cargo.lock...')
114
+ await run(['cargo', 'generate-lockfile'], path.join(root, 'desktop/src-tauri'))
115
+
116
+ // Git commit + tag
117
+ console.log(' Creating git commit...')
118
+ await run([
119
+ 'git',
120
+ 'add',
121
+ 'desktop/package.json',
122
+ 'desktop/src-tauri/tauri.conf.json',
123
+ 'desktop/src-tauri/Cargo.toml',
124
+ 'desktop/src-tauri/Cargo.lock',
125
+ ])
126
+ await run(['git', 'commit', '-m', `release: v${next}`])
127
+ await run(['git', 'tag', '-a', `v${next}`, '-m', `Release v${next}`])
128
+
129
+ console.log(`\n Done! Created commit and tag v${next}`)
130
+ console.log(`\n To trigger the build:\n git push origin main --tags\n`)