zencommit 0.1.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 (43) hide show
  1. package/README.md +421 -0
  2. package/bin/zencommit.js +37 -0
  3. package/package.json +68 -0
  4. package/scripts/install.mjs +146 -0
  5. package/scripts/platform.mjs +34 -0
  6. package/src/auth/secrets.ts +234 -0
  7. package/src/commands/auth.ts +138 -0
  8. package/src/commands/config.ts +83 -0
  9. package/src/commands/default.ts +322 -0
  10. package/src/commands/models.ts +67 -0
  11. package/src/config/load.test.ts +47 -0
  12. package/src/config/load.ts +118 -0
  13. package/src/config/merge.test.ts +25 -0
  14. package/src/config/merge.ts +30 -0
  15. package/src/config/types.ts +119 -0
  16. package/src/config/validate.ts +139 -0
  17. package/src/git/commit.ts +17 -0
  18. package/src/git/diff.ts +89 -0
  19. package/src/git/repo.ts +10 -0
  20. package/src/index.ts +207 -0
  21. package/src/llm/generate.ts +188 -0
  22. package/src/llm/prompt-template.ts +44 -0
  23. package/src/llm/prompt.ts +83 -0
  24. package/src/llm/prompts/base.md +119 -0
  25. package/src/llm/prompts/conventional.md +123 -0
  26. package/src/llm/prompts/gitmoji.md +212 -0
  27. package/src/llm/prompts/system.md +21 -0
  28. package/src/llm/providers.ts +102 -0
  29. package/src/llm/tokens.test.ts +22 -0
  30. package/src/llm/tokens.ts +46 -0
  31. package/src/llm/truncate.test.ts +60 -0
  32. package/src/llm/truncate.ts +552 -0
  33. package/src/metadata/cache.ts +28 -0
  34. package/src/metadata/index.ts +94 -0
  35. package/src/metadata/providers/local.ts +66 -0
  36. package/src/metadata/providers/modelsdev.ts +145 -0
  37. package/src/metadata/types.ts +20 -0
  38. package/src/ui/editor.ts +33 -0
  39. package/src/ui/prompts.ts +99 -0
  40. package/src/util/exec.ts +57 -0
  41. package/src/util/fs.ts +46 -0
  42. package/src/util/logger.ts +50 -0
  43. package/src/util/redact.ts +30 -0
package/README.md ADDED
@@ -0,0 +1,421 @@
1
+ # zencommit
2
+
3
+ AI-powered git commit message generator. Analyzes your staged changes and generates meaningful, conventional commit messages using LLMs.
4
+
5
+ ## Features
6
+
7
+ - **Smart Diff Analysis** - Automatically parses and prioritizes code changes for optimal context
8
+ - **Auto Token Capping** - Intelligently truncates diffs based on model token limits
9
+ - **Multiple Providers** - Supports 20+ AI providers via Vercel AI SDK
10
+ - **Conventional Commits** - Generates messages following conventional commit standards
11
+ - **Interactive Workflow** - Preview, edit, or confirm before committing
12
+ - **Flexible Configuration** - Global, project, and CLI-level configuration options
13
+ - **Secure Credential Storage** - API keys stored securely via Bun's Secrets API
14
+
15
+ ## Installation
16
+
17
+ ### Using npm
18
+
19
+ ```bash
20
+ npm install -g zencommit
21
+ ```
22
+
23
+ ### Using Bun
24
+
25
+ ```bash
26
+ bun install -g zencommit
27
+ ```
28
+
29
+ ### From Source
30
+
31
+ ```bash
32
+ git clone https://github.com/mboisvertdupras/zencommit.git
33
+ cd zencommit
34
+ bun install
35
+ bun run build:exe
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ 1. **Set up your API key:**
41
+
42
+ ```bash
43
+ zencommit auth login
44
+ ```
45
+
46
+ 2. **Stage your changes and run:**
47
+
48
+ ```bash
49
+ git add .
50
+ zencommit
51
+ ```
52
+
53
+ 3. **Review, edit, or confirm the generated commit message.**
54
+
55
+ ## Usage
56
+
57
+ ### Basic Commands
58
+
59
+ ```bash
60
+ # Generate commit message for staged changes
61
+ zencommit
62
+
63
+ # Auto-commit without confirmation
64
+ zencommit --yes
65
+
66
+ # Preview without committing
67
+ zencommit --dry-run
68
+
69
+ # Stage all changes and generate message
70
+ zencommit --all
71
+
72
+ # Generate message for unstaged changes (preview only)
73
+ zencommit --unstaged
74
+ ```
75
+
76
+ ### Command-Line Options
77
+
78
+ | Flag | Description |
79
+ | ------------------ | --------------------------------------------------- |
80
+ | `--yes` | Skip confirmation and commit immediately |
81
+ | `--dry-run` | Preview output without committing |
82
+ | `--all` | Stage all changes (`git add -A`) before generating |
83
+ | `--unstaged` | Use unstaged diff (never commits unless `--commit`) |
84
+ | `--commit` | Allow committing with `--unstaged` |
85
+ | `--model <id>` | Override model (e.g., `openai/gpt-4o`) |
86
+ | `--format <style>` | Commit style: `conventional` or `freeform` |
87
+ | `--lang <code>` | Language code (e.g., `en`, `fr`, `es`) |
88
+ | `--no-body` | Generate subject line only |
89
+ | `-v, -vv, -vvv` | Increase verbosity level |
90
+ | `--` | Pass additional arguments to `git commit` |
91
+
92
+ ### Examples
93
+
94
+ ```bash
95
+ # Use a specific model
96
+ zencommit --model anthropic/claude-sonnet-4-20250514
97
+
98
+ # Generate in French
99
+ zencommit --lang fr
100
+
101
+ # Freeform style without body
102
+ zencommit --format freeform --no-body
103
+
104
+ # Pass args to git commit
105
+ zencommit --yes -- --no-verify
106
+
107
+ # Verbose output for debugging
108
+ zencommit -vv --dry-run
109
+ ```
110
+
111
+ ## Authentication
112
+
113
+ Manage API keys with the `auth` command. Keys are stored securely via Bun's Secrets API and never written to config files.
114
+
115
+ ```bash
116
+ # Interactive login
117
+ zencommit auth login
118
+
119
+ # Non-interactive login
120
+ zencommit auth login --env-key OPENAI_API_KEY --token sk-...
121
+
122
+ # Remove stored key
123
+ zencommit auth logout --env-key OPENAI_API_KEY
124
+
125
+ # Check authentication status
126
+ zencommit auth status
127
+ ```
128
+
129
+ ### Supported Environment Variables
130
+
131
+ | Provider | Environment Variable |
132
+ | ----------- | -------------------------------------------------- |
133
+ | OpenAI | `OPENAI_API_KEY` |
134
+ | Anthropic | `ANTHROPIC_API_KEY` |
135
+ | Google | `GOOGLE_GENERATIVE_AI_API_KEY` or `GEMINI_API_KEY` |
136
+ | Azure | `AZURE_API_KEY` |
137
+ | AWS Bedrock | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
138
+ | Groq | `GROQ_API_KEY` |
139
+ | Mistral | `MISTRAL_API_KEY` |
140
+ | xAI | `XAI_API_KEY` |
141
+ | OpenRouter | `OPENROUTER_API_KEY` |
142
+ | Together AI | `TOGETHER_AI_API_KEY` |
143
+ | Perplexity | `PERPLEXITY_API_KEY` |
144
+ | Cohere | `COHERE_API_KEY` |
145
+ | Cerebras | `CEREBRAS_API_KEY` |
146
+ | DeepInfra | `DEEPINFRA_API_KEY` |
147
+ | GitLab | `GITLAB_API_KEY` |
148
+ | Vercel | `VERCEL_API_KEY` |
149
+
150
+ ## Configuration
151
+
152
+ Configuration is loaded from multiple sources and merged in order:
153
+
154
+ 1. **Global** - `~/.config/zencommit/config.json`
155
+ 2. **Custom path** - `$ZENCOMMIT_CONFIG`
156
+ 3. **Project** - `zencommit.json` in repository root
157
+ 4. **Inline** - `$ZENCOMMIT_CONFIG_CONTENT` (JSON string)
158
+
159
+ ### Initialize Config
160
+
161
+ ```bash
162
+ zencommit config init
163
+ ```
164
+
165
+ ### View Resolved Config
166
+
167
+ ```bash
168
+ zencommit config print
169
+ ```
170
+
171
+ ### Validate Config
172
+
173
+ ```bash
174
+ zencommit config validate
175
+ ```
176
+
177
+ ### Full Configuration Schema
178
+
179
+ ```json
180
+ {
181
+ "$schema": "https://zencommit.dev/config.json",
182
+ "ai": {
183
+ "model": "openai/gpt-4o",
184
+ "temperature": 0.2,
185
+ "maxOutputTokens": 4096,
186
+ "timeoutMs": 20000,
187
+ "openaiCompatible": {
188
+ "baseUrl": "https://example.com/v1",
189
+ "name": "my-provider"
190
+ }
191
+ },
192
+ "commit": {
193
+ "style": "conventional",
194
+ "language": "en",
195
+ "includeBody": true,
196
+ "emoji": false
197
+ },
198
+ "git": {
199
+ "diffMode": "staged",
200
+ "autoStage": false,
201
+ "confirmBeforeCommit": true
202
+ },
203
+ "diff": {
204
+ "truncateStrategy": "smart",
205
+ "includeFileList": true,
206
+ "excludeGitignoreFiles": true,
207
+ "maxFiles": 200,
208
+ "smart": {
209
+ "maxAddedLinesPerHunk": 12,
210
+ "maxRemovedLinesPerHunk": 12
211
+ }
212
+ },
213
+ "metadata": {
214
+ "provider": "auto",
215
+ "fallbackOrder": ["modelsdev", "local"],
216
+ "providers": {
217
+ "modelsdev": {
218
+ "url": "https://models.dev/api.json",
219
+ "cacheTtlHours": 24
220
+ },
221
+ "local": {
222
+ "path": "./models.metadata.json"
223
+ }
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ ### Configuration Options
230
+
231
+ #### AI Settings (`ai`)
232
+
233
+ | Option | Type | Default | Description |
234
+ | ------------------ | ------ | ----------------- | ---------------------------------------- |
235
+ | `model` | string | `"openai/gpt-4o"` | Model ID in `provider/model` format |
236
+ | `temperature` | number | `0.2` | Sampling temperature (0-1) |
237
+ | `maxOutputTokens` | number | `4096` | Maximum tokens in response |
238
+ | `timeoutMs` | number | `20000` | Request timeout in milliseconds |
239
+ | `openaiCompatible` | object | - | Settings for OpenAI-compatible providers |
240
+
241
+ #### Commit Settings (`commit`)
242
+
243
+ | Option | Type | Default | Description |
244
+ | ------------- | ------- | ---------------- | -------------------------------- |
245
+ | `style` | string | `"conventional"` | `"conventional"` or `"freeform"` |
246
+ | `language` | string | `"en"` | Language code for commit message |
247
+ | `includeBody` | boolean | `true` | Include detailed body in message |
248
+ | `emoji` | boolean | `false` | Include emoji in commit subject |
249
+
250
+ #### Git Settings (`git`)
251
+
252
+ | Option | Type | Default | Description |
253
+ | --------------------- | ------- | ---------- | ------------------------------------ |
254
+ | `diffMode` | string | `"staged"` | `"staged"`, `"unstaged"`, or `"all"` |
255
+ | `autoStage` | boolean | `false` | Auto-stage changes before generating |
256
+ | `confirmBeforeCommit` | boolean | `true` | Prompt before committing |
257
+
258
+ #### Diff Settings (`diff`)
259
+
260
+ | Option | Type | Default | Description |
261
+ | ------------------------------ | ------- | --------- | ----------------------------- |
262
+ | `truncateStrategy` | string | `"smart"` | `"smart"` or `"byFile"` |
263
+ | `includeFileList` | boolean | `true` | Include list of changed files |
264
+ | `excludeGitignoreFiles` | boolean | `true` | Respect .gitignore |
265
+ | `maxFiles` | number | `200` | Maximum files to include |
266
+ | `smart.maxAddedLinesPerHunk` | number | `12` | Max added lines per hunk |
267
+ | `smart.maxRemovedLinesPerHunk` | number | `12` | Max removed lines per hunk |
268
+
269
+ ## Supported Providers
270
+
271
+ zencommit supports any provider compatible with the Vercel AI SDK:
272
+
273
+ | Provider | Model Format | Example |
274
+ | ----------------- | --------------------------- | ------------------------------------------- |
275
+ | OpenAI | `openai/<model>` | `openai/gpt-4o` |
276
+ | Anthropic | `anthropic/<model>` | `anthropic/claude-sonnet-4-20250514` |
277
+ | Google | `google/<model>` | `google/gemini-2.5-pro` |
278
+ | Azure OpenAI | `azure/<deployment>` | `azure/gpt-4o-deployment` |
279
+ | AWS Bedrock | `bedrock/<model>` | `bedrock/anthropic.claude-3-5-sonnet` |
280
+ | Google Vertex | `vertex/<model>` | `vertex/gemini-2.0-flash` |
281
+ | Vertex Anthropic | `vertex-anthropic/<model>` | `vertex-anthropic/claude-sonnet-4-20250514` |
282
+ | Groq | `groq/<model>` | `groq/llama-3.3-70b-versatile` |
283
+ | Mistral | `mistral/<model>` | `mistral/mistral-large-latest` |
284
+ | xAI | `xai/<model>` | `xai/grok-2` |
285
+ | OpenRouter | `openrouter/<model>` | `openrouter/anthropic/claude-3-opus` |
286
+ | Together AI | `togetherai/<model>` | `togetherai/meta-llama/Meta-Llama-3-70B` |
287
+ | Perplexity | `perplexity/<model>` | `perplexity/llama-3.1-sonar-large-128k` |
288
+ | Cohere | `cohere/<model>` | `cohere/command-r-plus` |
289
+ | Cerebras | `cerebras/<model>` | `cerebras/llama3.1-70b` |
290
+ | DeepInfra | `deepinfra/<model>` | `deepinfra/meta-llama/Llama-2-70b` |
291
+ | GitLab | `gitlab/<model>` | `gitlab/claude-3-5-sonnet` |
292
+ | Vercel | `vercel/<model>` | `vercel/v0-1.0-md` |
293
+ | AI Gateway | `gateway/<model>` | `gateway/openai/gpt-4o` |
294
+ | OpenAI Compatible | `openai-compatible/<model>` | `openai-compatible/my-model` |
295
+
296
+ ### Provider Aliases
297
+
298
+ For convenience, these aliases are also supported:
299
+
300
+ - `gemini` → `google`
301
+ - `amazon-bedrock`, `aws-bedrock` → `bedrock`
302
+ - `google-vertex`, `google-vertex-ai` → `vertex`
303
+ - `azure-openai` → `azure`
304
+ - `together.ai` → `togetherai`
305
+ - `xai-grok` → `xai`
306
+ - `open-router` → `openrouter`
307
+ - `vercel-ai-gateway`, `ai-gateway` → `gateway`
308
+
309
+ ## Model Discovery
310
+
311
+ Explore available models using the `models` command. Model metadata is fetched from [models.dev](https://models.dev) and cached locally.
312
+
313
+ ```bash
314
+ # Search for models
315
+ zencommit models search gpt-4
316
+
317
+ # Get detailed model info
318
+ zencommit models info openai/gpt-4o
319
+ ```
320
+
321
+ ## Smart Diff Truncation
322
+
323
+ zencommit automatically caps diff content to fit within model token limits:
324
+
325
+ ### `smart` Strategy (default)
326
+
327
+ 1. **File Summary** - Includes compact `name-status` and `numstat` output
328
+ 2. **Compact Diff** - Uses `--unified=0` to minimize context lines
329
+ 3. **Hunk Prioritization** - Scores and selects most informative hunks:
330
+ - Prioritizes source code over generated files
331
+ - Boosts hunks with definitions (functions, classes, types)
332
+ - Prefers smaller, more focused hunks
333
+ 4. **Graceful Degradation** - Falls back to summary-only when needed
334
+
335
+ ### `byFile` Strategy
336
+
337
+ Distributes token budget proportionally across files, ensuring each file gets a minimum allocation before distributing remaining tokens by size.
338
+
339
+ ## Exit Codes
340
+
341
+ | Code | Meaning |
342
+ | ---- | ------------------------------------- |
343
+ | `0` | Success |
344
+ | `2` | Configuration or authentication error |
345
+ | `3` | Git error or no diff available |
346
+ | `4` | Model/LLM call error |
347
+
348
+ ## Development
349
+
350
+ ### Prerequisites
351
+
352
+ - [Bun](https://bun.sh/) >= 1.0
353
+ - Node.js >= 18 (for npm compatibility)
354
+
355
+ ### Commands
356
+
357
+ ```bash
358
+ # Install dependencies
359
+ bun install
360
+
361
+ # Run directly
362
+ bun src/index.ts
363
+
364
+ # Lint
365
+ bun run lint
366
+ bun run lint:fix
367
+
368
+ # Format
369
+ bun run format
370
+ bun run format:check
371
+
372
+ # Run tests
373
+ bun run test
374
+
375
+ # Build standalone executable
376
+ bun run build:exe
377
+ ```
378
+
379
+ ### Project Structure
380
+
381
+ ```
382
+ zencommit/
383
+ ├── src/
384
+ │ ├── index.ts # CLI entry point (yargs)
385
+ │ ├── commands/ # Command implementations
386
+ │ │ ├── default.ts # Main commit generation
387
+ │ │ ├── auth.ts # Authentication commands
388
+ │ │ ├── config.ts # Configuration commands
389
+ │ │ └── models.ts # Model discovery commands
390
+ │ ├── config/ # Configuration loading/merging
391
+ │ ├── auth/ # Secrets management
392
+ │ ├── metadata/ # Model metadata providers
393
+ │ ├── git/ # Git operations
394
+ │ ├── llm/ # LLM interactions & tokenization
395
+ │ ├── ui/ # Interactive prompts
396
+ │ └── util/ # Utility functions
397
+ ├── tests/ # Test files
398
+ ├── docs/ # Documentation
399
+ └── bin/ # Executable scripts
400
+ ```
401
+
402
+ ## Contributing
403
+
404
+ 1. Fork the repository
405
+ 2. Create a feature branch: `git checkout -b feat/my-feature`
406
+ 3. Make your changes following the coding style
407
+ 4. Run linting and tests: `bun run lint && bun run test`
408
+ 5. Commit using conventional commits: `feat: add my feature`
409
+ 6. Push and open a pull request
410
+
411
+ ## License
412
+
413
+ MIT
414
+
415
+ ## Acknowledgments
416
+
417
+ - [Vercel AI SDK](https://sdk.vercel.ai/) for unified LLM access
418
+ - [models.dev](https://models.dev) for model metadata
419
+ - [Bun](https://bun.sh/) for fast JavaScript runtime
420
+ - [yargs](https://yargs.js.org/) for CLI parsing
421
+ - [@clack/prompts](https://github.com/bombshell-dev/clack) for beautiful prompts
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { getInstallPath } from '../scripts/platform.mjs';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const pkgRoot = path.resolve(__dirname, '..');
10
+
11
+ let binaryPath;
12
+ try {
13
+ binaryPath = getInstallPath(pkgRoot);
14
+ } catch (error) {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ console.error(`zencommit: ${message}`);
17
+ process.exit(1);
18
+ }
19
+
20
+ if (!fs.existsSync(binaryPath)) {
21
+ console.error('zencommit: binary not found.');
22
+ console.error('zencommit: reinstall the package to download the executable.');
23
+ process.exit(1);
24
+ }
25
+
26
+ const child = spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit' });
27
+ child.on('error', (error) => {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ console.error(`zencommit: failed to launch: ${message}`);
30
+ process.exit(1);
31
+ });
32
+ child.on('exit', (code, signal) => {
33
+ if (signal) {
34
+ process.exit(1);
35
+ }
36
+ process.exit(code ?? 1);
37
+ });
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "zencommit",
3
+ "version": "0.1.0",
4
+ "module": "./src/index.ts",
5
+ "type": "module",
6
+ "private": false,
7
+ "bin": {
8
+ "zencommit": "bin/zencommit.js"
9
+ },
10
+ "files": ["src", "bin", "scripts"],
11
+ "scripts": {
12
+ "lint": "eslint .",
13
+ "lint:fix": "eslint . --fix",
14
+ "format": "prettier --write .",
15
+ "format:check": "prettier --check .",
16
+ "test": "vitest run",
17
+ "build:exe": "bun build --compile src/index.ts --outfile dist/zencommit",
18
+ "postinstall": "node scripts/install.mjs"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org"
26
+ },
27
+ "dependencies": {
28
+ "@ai-sdk/amazon-bedrock": "^4.0.39",
29
+ "@ai-sdk/anthropic": "^3.0.30",
30
+ "@ai-sdk/azure": "^3.0.23",
31
+ "@ai-sdk/cerebras": "^2.0.26",
32
+ "@ai-sdk/cohere": "^3.0.14",
33
+ "@ai-sdk/deepinfra": "^2.0.25",
34
+ "@ai-sdk/gateway": "^3.0.28",
35
+ "@ai-sdk/google": "^3.0.17",
36
+ "@ai-sdk/google-vertex": "^4.0.36",
37
+ "@ai-sdk/groq": "^3.0.18",
38
+ "@ai-sdk/mistral": "^3.0.15",
39
+ "@ai-sdk/openai": "^3.0.22",
40
+ "@ai-sdk/openai-compatible": "^2.0.23",
41
+ "@ai-sdk/perplexity": "^3.0.14",
42
+ "@ai-sdk/togetherai": "^2.0.26",
43
+ "@ai-sdk/vercel": "^2.0.25",
44
+ "@ai-sdk/xai": "^3.0.42",
45
+ "@clack/prompts": "^1.0.0",
46
+ "@dqbd/tiktoken": "^1.0.22",
47
+ "@gitlab/gitlab-ai-provider": "^3.3.1",
48
+ "@openrouter/ai-sdk-provider": "^2.1.1",
49
+ "ai": "^6.0.61",
50
+ "yargs": "^18.0.0",
51
+ "yocto-spinner": "^1.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/bun": "latest",
55
+ "@types/node": "^25.1.0",
56
+ "@typescript-eslint/eslint-plugin": "^8.54.0",
57
+ "@typescript-eslint/parser": "^8.54.0",
58
+ "eslint": "^9.39.2",
59
+ "eslint-config-prettier": "^10.1.8",
60
+ "globals": "^17.2.0",
61
+ "prettier": "^3.8.1",
62
+ "typescript": "^5.9.3",
63
+ "vitest": "^4.0.18"
64
+ },
65
+ "peerDependencies": {
66
+ "typescript": "^5.9.3"
67
+ }
68
+ }
@@ -0,0 +1,146 @@
1
+ import fs from 'node:fs/promises';
2
+ import { createWriteStream } from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import https from 'node:https';
6
+ import http from 'node:http';
7
+ import { Transform } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { getAssetName, getInstallPath } from './platform.mjs';
11
+
12
+ const REPO = 'mboisvertdupras/zencommit';
13
+ const USER_AGENT = 'zencommit-installer';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const pkgRoot = path.resolve(__dirname, '..');
17
+
18
+ const skipChecksum =
19
+ process.env.ZENCOMMIT_SKIP_CHECKSUM === '1' || process.env.ZENCOMMIT_SKIP_CHECKSUM === 'true';
20
+
21
+ function getReleaseBase(version) {
22
+ const base =
23
+ process.env.ZENCOMMIT_RELEASES_BASE_URL ??
24
+ `https://github.com/${REPO}/releases/download`;
25
+ const normalized = base.replace(/\/$/, '');
26
+ return `${normalized}/v${version}`;
27
+ }
28
+
29
+ function request(url, redirects = 5) {
30
+ return new Promise((resolve, reject) => {
31
+ const client = url.startsWith('https:') ? https : http;
32
+ const req = client.get(
33
+ url,
34
+ {
35
+ headers: { 'User-Agent': USER_AGENT },
36
+ },
37
+ (res) => {
38
+ const status = res.statusCode ?? 0;
39
+ const location = res.headers.location;
40
+ const isRedirect = [301, 302, 303, 307, 308].includes(status);
41
+ if (isRedirect && location) {
42
+ if (redirects <= 0) {
43
+ res.resume();
44
+ reject(new Error(`Too many redirects for ${url}`));
45
+ return;
46
+ }
47
+ res.resume();
48
+ const nextUrl = new URL(location, url).toString();
49
+ request(nextUrl, redirects - 1).then(resolve, reject);
50
+ return;
51
+ }
52
+ if (status < 200 || status >= 300) {
53
+ res.resume();
54
+ reject(new Error(`Request failed: ${status} ${res.statusMessage ?? ''} (${url})`));
55
+ return;
56
+ }
57
+ resolve(res);
58
+ },
59
+ );
60
+ req.on('error', reject);
61
+ });
62
+ }
63
+
64
+ async function downloadText(url) {
65
+ const res = await request(url);
66
+ const chunks = [];
67
+ for await (const chunk of res) {
68
+ chunks.push(chunk);
69
+ }
70
+ return Buffer.concat(chunks).toString('utf8');
71
+ }
72
+
73
+ async function downloadFile(url, destPath) {
74
+ const res = await request(url);
75
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
76
+ const hash = crypto.createHash('sha256');
77
+ const hashStream = new Transform({
78
+ transform(chunk, _encoding, callback) {
79
+ hash.update(chunk);
80
+ callback(null, chunk);
81
+ },
82
+ });
83
+ await pipeline(res, hashStream, createWriteStream(destPath));
84
+ return hash.digest('hex');
85
+ }
86
+
87
+ function parseChecksum(text, assetName) {
88
+ const lines = text.split(/\r?\n/).map((line) => line.trim());
89
+ for (const line of lines) {
90
+ if (!line) continue;
91
+ const [hash, file] = line.split(/\s+/);
92
+ if (!hash || !file) continue;
93
+ const normalized = file.replace(/^\*/, '');
94
+ if (normalized === assetName) {
95
+ return hash;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ async function main() {
102
+ const pkgRaw = await fs.readFile(path.join(pkgRoot, 'package.json'), 'utf8');
103
+ const pkg = JSON.parse(pkgRaw);
104
+ const version = pkg.version;
105
+ if (!version) {
106
+ throw new Error('package.json version is missing');
107
+ }
108
+
109
+ const assetName = getAssetName(version);
110
+ const installPath = getInstallPath(pkgRoot);
111
+ const tmpPath = `${installPath}.tmp`;
112
+ const releaseBase = getReleaseBase(version);
113
+ const assetUrl = `${releaseBase}/${assetName}`;
114
+ const checksumsUrl = `${releaseBase}/checksums.txt`;
115
+
116
+ let expectedHash = null;
117
+ if (!skipChecksum) {
118
+ const checksums = await downloadText(checksumsUrl);
119
+ expectedHash = parseChecksum(checksums, assetName);
120
+ if (!expectedHash) {
121
+ throw new Error(`Missing checksum for ${assetName}`);
122
+ }
123
+ }
124
+
125
+ await fs.rm(installPath, { force: true });
126
+ await fs.rm(tmpPath, { force: true });
127
+ const actualHash = await downloadFile(assetUrl, tmpPath);
128
+
129
+ if (!skipChecksum && expectedHash && actualHash !== expectedHash) {
130
+ await fs.rm(tmpPath, { force: true });
131
+ throw new Error(`Checksum mismatch for ${assetName}`);
132
+ }
133
+
134
+ await fs.rename(tmpPath, installPath);
135
+ if (process.platform !== 'win32') {
136
+ await fs.chmod(installPath, 0o755);
137
+ }
138
+
139
+ console.log(`Installed ${assetName}`);
140
+ }
141
+
142
+ main().catch((error) => {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ console.error(`zencommit install failed: ${message}`);
145
+ process.exit(1);
146
+ });