agnix 0.37.1 → 0.37.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.
Files changed (2) hide show
  1. package/install.js +156 -25
  2. package/package.json +2 -2
package/install.js CHANGED
@@ -5,10 +5,35 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { execSync } = require('child_process');
7
7
  const os = require('os');
8
+ const crypto = require('crypto');
8
9
 
9
10
  const GITHUB_REPO = 'agent-sh/agnix';
10
11
  const VERSION = require('./package.json').version;
11
12
 
13
+ function removeIfExists(filePath) {
14
+ try {
15
+ if (fs.existsSync(filePath)) {
16
+ fs.unlinkSync(filePath);
17
+ }
18
+ } catch (_) {
19
+ // best-effort cleanup
20
+ }
21
+ }
22
+
23
+ function restoreWrapperBackup(wrapperPath, wrapperBackup, installCompleted) {
24
+ if (!fs.existsSync(wrapperBackup)) {
25
+ return;
26
+ }
27
+
28
+ if (installCompleted) {
29
+ removeIfExists(wrapperBackup);
30
+ return;
31
+ }
32
+
33
+ removeIfExists(wrapperPath);
34
+ fs.renameSync(wrapperBackup, wrapperPath);
35
+ }
36
+
12
37
  /**
13
38
  * Get platform-specific asset name and binary name.
14
39
  */
@@ -62,43 +87,60 @@ function getPlatformInfo() {
62
87
  */
63
88
  function downloadFile(url, destPath) {
64
89
  return new Promise((resolve, reject) => {
65
- const file = fs.createWriteStream(destPath);
90
+ let file = null;
91
+ let settled = false;
92
+
93
+ const fail = (err) => {
94
+ if (settled) {
95
+ return;
96
+ }
97
+ settled = true;
98
+ if (file) {
99
+ file.destroy();
100
+ }
101
+ removeIfExists(destPath);
102
+ reject(err);
103
+ };
66
104
 
67
105
  const request = https.get(url, (response) => {
68
106
  if (response.statusCode === 302 || response.statusCode === 301) {
69
107
  const redirectUrl = response.headers.location;
70
108
  if (redirectUrl) {
71
- file.close();
72
- fs.unlinkSync(destPath);
73
- downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
109
+ response.resume();
110
+ const nextUrl = new URL(redirectUrl, url).href;
111
+ downloadFile(nextUrl, destPath).then(resolve).catch(reject);
74
112
  return;
75
113
  }
76
114
  }
77
115
 
78
116
  if (response.statusCode !== 200) {
79
- file.close();
80
- reject(new Error(`Download failed with status ${response.statusCode}`));
117
+ response.resume();
118
+ fail(new Error(`Download failed with status ${response.statusCode}`));
81
119
  return;
82
120
  }
83
121
 
122
+ file = fs.createWriteStream(destPath);
84
123
  response.pipe(file);
124
+ response.on('error', fail);
85
125
 
86
126
  file.on('finish', () => {
87
- file.close();
88
- resolve();
127
+ if (settled) {
128
+ return;
129
+ }
130
+ settled = true;
131
+ file.close((err) => {
132
+ if (err) {
133
+ removeIfExists(destPath);
134
+ reject(err);
135
+ } else {
136
+ resolve();
137
+ }
138
+ });
89
139
  });
140
+ file.on('error', fail);
90
141
  });
91
142
 
92
- request.on('error', (err) => {
93
- file.close();
94
- fs.unlinkSync(destPath);
95
- reject(err);
96
- });
97
-
98
- file.on('error', (err) => {
99
- fs.unlinkSync(destPath);
100
- reject(err);
101
- });
143
+ request.on('error', fail);
102
144
  });
103
145
  }
104
146
 
@@ -120,11 +162,81 @@ function extractArchive(archivePath, destDir) {
120
162
  }
121
163
  }
122
164
 
165
+ /**
166
+ * Compute the SHA-256 of a file as a lowercase hex string, streaming the file
167
+ * so large archives are not read fully into memory.
168
+ */
169
+ function sha256File(filePath) {
170
+ return new Promise((resolve, reject) => {
171
+ const hash = crypto.createHash('sha256');
172
+ const stream = fs.createReadStream(filePath);
173
+ stream.on('error', reject);
174
+ stream.on('data', (chunk) => hash.update(chunk));
175
+ stream.on('end', () => resolve(hash.digest('hex')));
176
+ });
177
+ }
178
+
179
+ function parseSha256Sidecar(contents, expectedFileName) {
180
+ const expectedBase = path.basename(expectedFileName);
181
+ for (const line of contents.split(/\r?\n/)) {
182
+ const trimmed = line.trim();
183
+ if (!trimmed) {
184
+ continue;
185
+ }
186
+
187
+ const [rawHash, rawFileName] = trimmed.split(/\s+/, 2);
188
+ if (!rawFileName) {
189
+ continue;
190
+ }
191
+
192
+ const normalizedFileName = rawFileName.replace(/^\*/, '').replace(/\\/g, '/');
193
+ const baseName = path.posix.basename(normalizedFileName);
194
+ if (baseName !== expectedBase) {
195
+ continue;
196
+ }
197
+
198
+ const expected = rawHash.toLowerCase();
199
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
200
+ throw new Error(`Invalid checksum file for ${expectedBase}`);
201
+ }
202
+
203
+ return expected;
204
+ }
205
+
206
+ throw new Error(`Checksum file does not contain an entry for ${expectedBase}`);
207
+ }
208
+
209
+ /**
210
+ * Download the `.sha256` sidecar for an archive and verify the archive against
211
+ * it before extraction, so a tampered download is rejected (mirrors
212
+ * scripts/download.sh). Throws on a missing/malformed sidecar or hash mismatch.
213
+ */
214
+ async function verifyChecksum(archivePath, checksumUrl, deps = { downloadFile }) {
215
+ const checksumPath = `${archivePath}.sha256`;
216
+ try {
217
+ await deps.downloadFile(checksumUrl, checksumPath);
218
+ const raw = fs.readFileSync(checksumPath, 'utf8').trim();
219
+ const expected = parseSha256Sidecar(raw, path.basename(archivePath));
220
+ const actual = (await sha256File(archivePath)).toLowerCase();
221
+ if (actual !== expected) {
222
+ throw new Error(
223
+ `Checksum mismatch for ${path.basename(archivePath)}: expected ${expected}, got ${actual}`
224
+ );
225
+ }
226
+ console.log('Checksum verified.');
227
+ } finally {
228
+ removeIfExists(checksumPath);
229
+ }
230
+ }
231
+
123
232
  async function main() {
124
233
  const platformInfo = getPlatformInfo();
125
234
  const binDir = path.join(__dirname, 'bin');
126
235
  const binaryPath = path.join(binDir, platformInfo.binary);
127
236
  const extractedPath = path.join(binDir, platformInfo.extractedName);
237
+ const wrapperPath = path.join(binDir, 'agnix');
238
+ const wrapperBackup = path.join(binDir, 'agnix.backup');
239
+ let installCompleted = false;
128
240
 
129
241
  // Skip if binary already exists
130
242
  if (fs.existsSync(binaryPath)) {
@@ -144,19 +256,15 @@ async function main() {
144
256
 
145
257
  try {
146
258
  // Save wrapper script if it exists (npm places it during install)
147
- const wrapperPath = path.join(binDir, 'agnix');
148
- const wrapperBackup = path.join(binDir, 'agnix.backup');
149
259
  if (fs.existsSync(wrapperPath) && wrapperPath !== binaryPath) {
150
260
  fs.copyFileSync(wrapperPath, wrapperBackup);
151
261
  }
152
262
 
153
263
  await downloadFile(downloadUrl, archivePath);
264
+ await verifyChecksum(archivePath, `${downloadUrl}.sha256`);
154
265
  console.log('Extracting...');
155
266
  extractArchive(archivePath, binDir);
156
267
 
157
- // Clean up archive
158
- fs.unlinkSync(archivePath);
159
-
160
268
  // Rename extracted binary to avoid conflict with wrapper script
161
269
  if (fs.existsSync(extractedPath) && extractedPath !== binaryPath) {
162
270
  fs.renameSync(extractedPath, binaryPath);
@@ -177,14 +285,37 @@ async function main() {
177
285
  throw new Error('Binary not found after extraction');
178
286
  }
179
287
 
288
+ installCompleted = true;
180
289
  console.log('agnix installed successfully');
181
290
  } catch (error) {
182
291
  console.error(`Failed to install agnix: ${error.message}`);
183
292
  console.error('');
184
293
  console.error('You can install manually:');
185
294
  console.error(' cargo install agnix-cli');
186
- process.exit(1);
295
+ process.exitCode = 1;
296
+ } finally {
297
+ // Best-effort cleanup so a failed install leaves no stale archive or backup.
298
+ removeIfExists(archivePath);
299
+ try {
300
+ restoreWrapperBackup(wrapperPath, wrapperBackup, installCompleted);
301
+ } catch (_) {
302
+ // ignore
303
+ }
187
304
  }
188
305
  }
189
306
 
190
- main();
307
+ if (require.main === module) {
308
+ main();
309
+ }
310
+
311
+ module.exports = {
312
+ downloadFile,
313
+ extractArchive,
314
+ getPlatformInfo,
315
+ main,
316
+ parseSha256Sidecar,
317
+ removeIfExists,
318
+ restoreWrapperBackup,
319
+ sha256File,
320
+ verifyChecksum,
321
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agnix",
3
- "version": "0.37.1",
3
+ "version": "0.37.3",
4
4
  "description": "Linter for AI agent configurations. Validates SKILL.md, CLAUDE.md, hooks, MCP, and more.",
5
5
  "keywords": [
6
6
  "agent",
@@ -38,7 +38,7 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "postinstall": "node install.js",
41
- "test": "node test/test.js"
41
+ "test": "node test/install-helpers.test.js && node test/test.js"
42
42
  },
43
43
  "engines": {
44
44
  "node": ">=16.0.0"