ai-rulez 1.0.0 → 1.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 (2) hide show
  1. package/install.js +271 -37
  2. package/package.json +1 -1
package/install.js CHANGED
@@ -1,12 +1,46 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const https = require('https');
4
- const { exec } = require('child_process');
4
+ const http = require('http');
5
+ const crypto = require('crypto');
6
+ const { exec, spawn } = require('child_process');
5
7
  const { promisify } = require('util');
6
8
 
7
9
  const execAsync = promisify(exec);
8
10
 
9
11
  const REPO_NAME = 'Goldziher/ai-rulez';
12
+ const DOWNLOAD_TIMEOUT = 30000; // 30 seconds
13
+ const MAX_RETRIES = 3;
14
+ const RETRY_DELAY = 2000; // 2 seconds
15
+
16
+ async function calculateSHA256(filePath) {
17
+ return new Promise((resolve, reject) => {
18
+ const hash = crypto.createHash('sha256');
19
+ const stream = fs.createReadStream(filePath);
20
+
21
+ stream.on('data', (data) => hash.update(data));
22
+ stream.on('end', () => resolve(hash.digest('hex')));
23
+ stream.on('error', reject);
24
+ });
25
+ }
26
+
27
+ async function getExpectedChecksum(checksumPath, filename) {
28
+ try {
29
+ const checksumContent = fs.readFileSync(checksumPath, 'utf8');
30
+ const lines = checksumContent.split('\n');
31
+
32
+ for (const line of lines) {
33
+ const parts = line.trim().split(/\s+/);
34
+ if (parts.length >= 2 && parts[1] === filename) {
35
+ return parts[0];
36
+ }
37
+ }
38
+ return null;
39
+ } catch (error) {
40
+ console.warn('Warning: Could not parse checksums file:', error.message);
41
+ return null;
42
+ }
43
+ }
10
44
 
11
45
  function getPlatform() {
12
46
  const platform = process.platform;
@@ -28,13 +62,17 @@ function getPlatform() {
28
62
  const mappedPlatform = platformMap[platform];
29
63
  const mappedArch = archMap[arch];
30
64
 
31
- if (!mappedPlatform || !mappedArch) {
32
- throw new Error(`Unsupported platform: ${platform} ${arch}`);
65
+ if (!mappedPlatform) {
66
+ throw new Error(`Unsupported operating system: ${platform}. Supported platforms: darwin (macOS), linux, win32 (Windows)`);
67
+ }
68
+
69
+ if (!mappedArch) {
70
+ throw new Error(`Unsupported architecture: ${arch}. Supported architectures: x64, arm64, ia32`);
33
71
  }
34
72
 
35
- // Windows ARM64 is not supported in our builds
73
+
36
74
  if (mappedPlatform === 'windows' && mappedArch === 'arm64') {
37
- throw new Error('Windows ARM64 is not supported');
75
+ throw new Error('Windows ARM64 is not currently supported. Please use x64 or ia32 version.');
38
76
  }
39
77
 
40
78
  return {
@@ -47,81 +85,264 @@ function getBinaryName(platform) {
47
85
  return platform === 'windows' ? 'ai-rulez.exe' : 'ai-rulez';
48
86
  }
49
87
 
50
- async function downloadBinary(url, dest) {
88
+ async function downloadBinary(url, dest, retryCount = 0) {
51
89
  return new Promise((resolve, reject) => {
52
90
  const file = fs.createWriteStream(dest);
91
+ const protocol = url.startsWith('https') ? https : http;
53
92
 
54
- https.get(url, (response) => {
93
+ const request = protocol.get(url, { timeout: DOWNLOAD_TIMEOUT }, (response) => {
55
94
  if (response.statusCode === 302 || response.statusCode === 301) {
56
- // Handle redirect
57
- https.get(response.headers.location, (redirectResponse) => {
58
- redirectResponse.pipe(file);
59
- file.on('finish', () => {
60
- file.close();
61
- resolve();
62
- });
63
- }).on('error', reject);
64
- } else if (response.statusCode === 200) {
65
- response.pipe(file);
66
- file.on('finish', () => {
67
- file.close();
68
- resolve();
69
- });
70
- } else {
71
- reject(new Error(`Failed to download: ${response.statusCode}`));
95
+ file.close();
96
+ try { fs.unlinkSync(dest); } catch {} // Clean up partial file
97
+ downloadBinary(response.headers.location, dest, retryCount)
98
+ .then(resolve)
99
+ .catch(reject);
100
+ return;
101
+ }
102
+
103
+ if (response.statusCode !== 200) {
104
+ file.close();
105
+ try { fs.unlinkSync(dest); } catch {} // Clean up partial file
106
+ const error = new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`);
107
+
108
+ if (retryCount < MAX_RETRIES) {
109
+ console.log(`Download failed, retrying in ${RETRY_DELAY/1000}s... (${retryCount + 1}/${MAX_RETRIES})`);
110
+ setTimeout(() => {
111
+ downloadBinary(url, dest, retryCount + 1)
112
+ .then(resolve)
113
+ .catch(reject);
114
+ }, RETRY_DELAY);
115
+ return;
116
+ }
117
+
118
+ reject(error);
119
+ return;
120
+ }
121
+
122
+ let downloadedBytes = 0;
123
+ response.on('data', (chunk) => {
124
+ downloadedBytes += chunk.length;
125
+ });
126
+
127
+ response.pipe(file);
128
+
129
+ file.on('finish', () => {
130
+ file.close();
131
+ if (downloadedBytes === 0) {
132
+ try { fs.unlinkSync(dest); } catch {}
133
+ reject(new Error('Downloaded file is empty'));
134
+ return;
135
+ }
136
+ console.log(`Downloaded ${downloadedBytes} bytes`);
137
+ resolve();
138
+ });
139
+
140
+ file.on('error', (err) => {
141
+ file.close();
142
+ try { fs.unlinkSync(dest); } catch {}
143
+ reject(err);
144
+ });
145
+ });
146
+
147
+ request.on('timeout', () => {
148
+ request.destroy();
149
+ file.close();
150
+ try { fs.unlinkSync(dest); } catch {}
151
+
152
+ if (retryCount < MAX_RETRIES) {
153
+ console.log(`Download timeout, retrying in ${RETRY_DELAY/1000}s... (${retryCount + 1}/${MAX_RETRIES})`);
154
+ setTimeout(() => {
155
+ downloadBinary(url, dest, retryCount + 1)
156
+ .then(resolve)
157
+ .catch(reject);
158
+ }, RETRY_DELAY);
159
+ return;
72
160
  }
73
- }).on('error', reject);
161
+
162
+ reject(new Error('Download timeout after multiple retries'));
163
+ });
164
+
165
+ request.on('error', (err) => {
166
+ file.close();
167
+ try { fs.unlinkSync(dest); } catch {}
168
+
169
+ if (retryCount < MAX_RETRIES) {
170
+ console.log(`Download error, retrying in ${RETRY_DELAY/1000}s... (${retryCount + 1}/${MAX_RETRIES})`);
171
+ setTimeout(() => {
172
+ downloadBinary(url, dest, retryCount + 1)
173
+ .then(resolve)
174
+ .catch(reject);
175
+ }, RETRY_DELAY);
176
+ return;
177
+ }
178
+
179
+ reject(err);
180
+ });
74
181
  });
75
182
  }
76
183
 
77
184
  async function extractArchive(archivePath, extractDir, platform) {
78
185
  if (platform === 'windows') {
79
- // Use PowerShell to extract zip on Windows
80
- await execAsync(`powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`);
186
+ // Use safer PowerShell execution with proper escaping
187
+ const escapedArchivePath = archivePath.replace(/'/g, "''");
188
+ const escapedExtractDir = extractDir.replace(/'/g, "''");
189
+
190
+ const powershellCommand = [
191
+ 'powershell.exe',
192
+ '-NoProfile',
193
+ '-ExecutionPolicy', 'Bypass',
194
+ '-Command',
195
+ `Expand-Archive -LiteralPath '${escapedArchivePath}' -DestinationPath '${escapedExtractDir}' -Force`
196
+ ];
197
+
198
+ await new Promise((resolve, reject) => {
199
+ const child = spawn(powershellCommand[0], powershellCommand.slice(1), {
200
+ stdio: ['pipe', 'pipe', 'pipe'],
201
+ windowsHide: true
202
+ });
203
+
204
+ let stderr = '';
205
+ child.stderr.on('data', (data) => {
206
+ stderr += data.toString();
207
+ });
208
+
209
+ child.on('close', (code) => {
210
+ if (code === 0) {
211
+ resolve();
212
+ } else {
213
+ reject(new Error(`PowerShell extraction failed with code ${code}: ${stderr}`));
214
+ }
215
+ });
216
+
217
+ child.on('error', reject);
218
+ });
81
219
  } else {
82
- // Use tar for Unix-like systems
83
- await execAsync(`tar -xzf "${archivePath}" -C "${extractDir}"`);
220
+ // Use spawn instead of exec for better security and error handling
221
+ await new Promise((resolve, reject) => {
222
+ const child = spawn('tar', ['-xzf', archivePath, '-C', extractDir], {
223
+ stdio: ['pipe', 'pipe', 'pipe']
224
+ });
225
+
226
+ let stderr = '';
227
+ child.stderr.on('data', (data) => {
228
+ stderr += data.toString();
229
+ });
230
+
231
+ child.on('close', (code) => {
232
+ if (code === 0) {
233
+ resolve();
234
+ } else {
235
+ reject(new Error(`tar extraction failed with code ${code}: ${stderr}`));
236
+ }
237
+ });
238
+
239
+ child.on('error', reject);
240
+ });
84
241
  }
85
242
  }
86
243
 
87
244
  async function install() {
88
245
  try {
246
+ // Check Node.js version compatibility
247
+ const nodeVersion = process.version;
248
+ const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]);
249
+ if (majorVersion < 20) {
250
+ console.error(`Error: Node.js ${nodeVersion} is not supported. Please upgrade to Node.js 20 or later.`);
251
+ process.exit(1);
252
+ }
253
+
89
254
  const { os, arch } = getPlatform();
90
255
  const binaryName = getBinaryName(os);
91
256
 
92
- // Get version from package.json
257
+
93
258
  const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
94
259
  const version = packageJson.version;
95
260
 
96
- // Construct download URL
261
+
97
262
  const archiveExt = os === 'windows' ? 'zip' : 'tar.gz';
98
263
  const archiveName = `ai-rulez_${version}_${os}_${arch}.${archiveExt}`;
99
264
  const downloadUrl = `https://github.com/${REPO_NAME}/releases/download/v${version}/${archiveName}`;
265
+ const checksumUrl = `https://github.com/${REPO_NAME}/releases/download/v${version}/checksums.txt`;
100
266
 
101
267
  console.log(`Downloading ai-rulez ${version} for ${os}/${arch}...`);
102
268
  console.log(`URL: ${downloadUrl}`);
103
269
 
104
- // Create bin directory
270
+
105
271
  const binDir = path.join(__dirname, 'bin');
106
272
  if (!fs.existsSync(binDir)) {
107
273
  fs.mkdirSync(binDir, { recursive: true });
108
274
  }
109
275
 
110
- // Download archive
276
+
111
277
  const archivePath = path.join(__dirname, archiveName);
278
+
279
+ // Download checksums first for verification
280
+ console.log('Downloading checksums...');
281
+ const checksumPath = path.join(__dirname, 'checksums.txt');
282
+ try {
283
+ await downloadBinary(checksumUrl, checksumPath);
284
+ } catch (checksumError) {
285
+ console.warn('Warning: Could not download checksums, skipping verification');
286
+ }
287
+
112
288
  await downloadBinary(downloadUrl, archivePath);
113
289
 
114
- // Extract archive
290
+ // Verify checksum if available
291
+ if (fs.existsSync(checksumPath)) {
292
+ console.log('Verifying checksum...');
293
+ const expectedHash = await getExpectedChecksum(checksumPath, archiveName);
294
+ if (expectedHash) {
295
+ const actualHash = await calculateSHA256(archivePath);
296
+ if (actualHash !== expectedHash) {
297
+ throw new Error(`Checksum verification failed. Expected: ${expectedHash}, Got: ${actualHash}`);
298
+ }
299
+ console.log('✓ Checksum verified');
300
+ }
301
+ fs.unlinkSync(checksumPath);
302
+ }
303
+
304
+
115
305
  console.log('Extracting binary...');
116
306
  await extractArchive(archivePath, binDir, os);
117
307
 
118
- // Make binary executable on Unix-like systems
308
+
309
+ const binaryPath = path.join(binDir, binaryName);
310
+ if (!fs.existsSync(binaryPath)) {
311
+ throw new Error(`Binary not found after extraction: ${binaryPath}`);
312
+ }
313
+
314
+
119
315
  if (os !== 'windows') {
120
- const binaryPath = path.join(binDir, binaryName);
121
316
  fs.chmodSync(binaryPath, 0o755);
122
317
  }
123
318
 
124
- // Clean up archive
319
+ // Verify binary is executable
320
+ try {
321
+ await new Promise((resolve, reject) => {
322
+ const testCommand = os === 'windows' ? [binaryPath, '--version'] : [binaryPath, '--version'];
323
+ const child = spawn(testCommand[0], testCommand.slice(1), {
324
+ stdio: ['pipe', 'pipe', 'pipe'],
325
+ timeout: 5000
326
+ });
327
+
328
+ child.on('close', (code) => {
329
+ // Any exit code is fine, we just want to verify it can execute
330
+ resolve();
331
+ });
332
+
333
+ child.on('error', (err) => {
334
+ if (err.code === 'ENOENT') {
335
+ reject(new Error('Downloaded binary is not executable'));
336
+ } else {
337
+ resolve(); // Other errors are OK for version check
338
+ }
339
+ });
340
+ });
341
+ } catch (verifyError) {
342
+ console.warn('Warning: Could not verify binary execution:', verifyError.message);
343
+ }
344
+
345
+
125
346
  fs.unlinkSync(archivePath);
126
347
 
127
348
  console.log(`✅ ai-rulez ${version} installed successfully for ${os}/${arch}!`);
@@ -134,7 +355,20 @@ async function install() {
134
355
  }
135
356
  }
136
357
 
137
- // Only run install during postinstall
358
+
359
+ // Export functions for testing
360
+ if (typeof module !== 'undefined' && module.exports) {
361
+ module.exports = {
362
+ getPlatform,
363
+ getBinaryName,
364
+ downloadBinary,
365
+ extractArchive,
366
+ calculateSHA256,
367
+ getExpectedChecksum,
368
+ install
369
+ };
370
+ }
371
+
138
372
  if (require.main === module) {
139
373
  install();
140
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-rulez",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "⚡ Lightning-fast CLI tool (written in Go) for managing AI assistant rules - generate configuration files for Claude, Cursor, Windsurf and more",
5
5
  "keywords": [
6
6
  "ai",