@qoder-ai/qodercli 0.2.5-beta → 0.2.6

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.
@@ -0,0 +1,670 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-unused-vars, @typescript-eslint/no-this-alias, no-useless-catch, no-empty */
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const https = require('https');
7
+ const http = require('http');
8
+ const { execSync } = require('child_process');
9
+ const crypto = require('crypto');
10
+ const os = require('os');
11
+
12
+ // Configuration
13
+ const BINARY_NAME = 'qodercli';
14
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
15
+ const BIN_DIR = path.join(PACKAGE_ROOT, 'bin');
16
+ const PACKAGE_JSON_PATH = path.join(PACKAGE_ROOT, 'package.json');
17
+ const INSTALL_METHOD = 'npm';
18
+
19
+ class QoderInstaller {
20
+ constructor() {
21
+ // Mark this as an installation process
22
+ process.env.QODER_CLI_INSTALL = '1';
23
+
24
+ // Initialize logging variables
25
+ this.logFile = null;
26
+ this.logStream = null;
27
+ this.originalConsoleLog = console.log;
28
+ this.originalConsoleError = console.error;
29
+
30
+ // Setup logging first to capture all output including platform detection
31
+ this.setupLogging();
32
+
33
+ // Detect platform and architecture (will be logged if they fail)
34
+ this.platform = this.detectPlatform();
35
+ this.arch = this.detectArch();
36
+ this.binPath = path.join(
37
+ BIN_DIR,
38
+ BINARY_NAME + (process.platform === 'win32' ? '.exe' : ''),
39
+ );
40
+ this.packageInfo = this.loadPackageInfo();
41
+ }
42
+
43
+ detectPlatform() {
44
+ switch (process.platform) {
45
+ case 'darwin':
46
+ return 'darwin';
47
+ case 'linux':
48
+ return 'linux';
49
+ case 'win32':
50
+ return 'windows';
51
+ default:
52
+ throw new Error(`Unsupported platform: ${process.platform}`);
53
+ }
54
+ }
55
+
56
+ detectArch() {
57
+ const arch = process.arch;
58
+ switch (arch) {
59
+ case 'x64':
60
+ // On Linux x64, check if CPU supports AVX2 instructions.
61
+ // Bun optimized binaries require AVX2/BMI2/FMA3 (-march=haswell).
62
+ // CPUs with only AVX1 (e.g. Ivy Bridge) will SIGILL on BMI2 instructions.
63
+ if (process.platform === 'linux' && !this.hasAVX2()) {
64
+ return 'amd64-baseline';
65
+ }
66
+ return 'amd64';
67
+ case 'arm64':
68
+ return 'arm64';
69
+ default:
70
+ throw new Error(`Unsupported architecture: ${arch}`);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Check if the CPU supports AVX2 instructions by reading /proc/cpuinfo.
76
+ * Bun optimized binaries are compiled with -march=haswell, requiring
77
+ * AVX2/BMI1/BMI2/FMA3. AVX1-only CPUs (e.g. Ivy Bridge) will SIGILL.
78
+ * Returns true if AVX2 is present or detection is unavailable.
79
+ * Only returns false when we can confirm AVX2 is absent.
80
+ */
81
+ hasAVX2() {
82
+ try {
83
+ const cpuinfo = fs.readFileSync('/proc/cpuinfo', 'utf-8');
84
+ return /^flags\s*:.*\bavx2\b/m.test(cpuinfo);
85
+ } catch (e) {
86
+ // If we can't read cpuinfo, assume AVX2 is present (safer default)
87
+ return true;
88
+ }
89
+ }
90
+
91
+ setupLogging() {
92
+ try {
93
+ // Create log directory: ~/.qoder/logs on all platforms
94
+ const logDir = path.join(os.homedir(), '.qoder', 'logs');
95
+ if (!fs.existsSync(logDir)) {
96
+ fs.mkdirSync(logDir, { recursive: true });
97
+ }
98
+
99
+ // Create log file with timestamp
100
+ const timestamp = new Date()
101
+ .toISOString()
102
+ .replace(/[:.]/g, '-')
103
+ .replace('T', '_')
104
+ .split('Z')[0];
105
+ this.logFile = path.join(
106
+ logDir,
107
+ `qodercli_install_${INSTALL_METHOD}_${timestamp}.log`,
108
+ );
109
+
110
+ // Create write stream
111
+ this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' });
112
+
113
+ // Write log header
114
+ this.logStream.write(
115
+ `Installation started at ${new Date().toISOString()}\n`,
116
+ );
117
+ this.logStream.write(`Installation method: ${INSTALL_METHOD}\n`);
118
+ this.logStream.write(`Platform: ${process.platform}/${process.arch}\n`);
119
+ this.logStream.write(`Node.js version: ${process.version}\n`);
120
+ this.logStream.write('================================\n\n');
121
+
122
+ // Create latest log marker
123
+ // Unix: symlink immediately, Windows: will copy after completion
124
+ const latestLogLink = path.join(logDir, 'qodercli_install.log');
125
+ try {
126
+ if (process.platform !== 'win32') {
127
+ // Unix: use symlink (immediate)
128
+ if (fs.existsSync(latestLogLink)) {
129
+ fs.unlinkSync(latestLogLink);
130
+ }
131
+ fs.symlinkSync(this.logFile, latestLogLink);
132
+ }
133
+ // Windows: will copy complete log in closeLogging()
134
+ } catch (e) {
135
+ // Ignore errors - not critical
136
+ }
137
+
138
+ // Redirect console.log and console.error to both terminal and log file
139
+ const self = this;
140
+ console.log = function (...args) {
141
+ const message = args
142
+ .map((arg) =>
143
+ typeof arg === 'object'
144
+ ? JSON.stringify(arg, null, 2)
145
+ : String(arg),
146
+ )
147
+ .join(' ');
148
+ self.originalConsoleLog.apply(console, args);
149
+ if (self.logStream) {
150
+ self.logStream.write(message + '\n');
151
+ }
152
+ };
153
+
154
+ console.error = function (...args) {
155
+ const message = args
156
+ .map((arg) =>
157
+ typeof arg === 'object'
158
+ ? JSON.stringify(arg, null, 2)
159
+ : String(arg),
160
+ )
161
+ .join(' ');
162
+ self.originalConsoleError.apply(console, args);
163
+ if (self.logStream) {
164
+ self.logStream.write('[ERROR] ' + message + '\n');
165
+ }
166
+ };
167
+
168
+ // Log file location saved but not printed (will show on error)
169
+ } catch (error) {
170
+ // If logging setup fails, continue without logging
171
+ this.originalConsoleError.call(
172
+ console,
173
+ 'Warning: Failed to setup logging:',
174
+ error.message,
175
+ );
176
+ this.originalConsoleError.call(
177
+ console,
178
+ 'Installation will continue without logging',
179
+ );
180
+ }
181
+ }
182
+
183
+ closeLogging() {
184
+ if (this.logStream) {
185
+ this.logStream.end();
186
+ this.logStream = null;
187
+ }
188
+
189
+ // Update latest log marker after installation completes
190
+ if (this.logFile && process.platform === 'win32') {
191
+ try {
192
+ const logDir = path.dirname(this.logFile);
193
+ const latestLogLink = path.join(logDir, 'qodercli_install.log');
194
+ fs.copyFileSync(this.logFile, latestLogLink);
195
+ } catch (e) {
196
+ // Ignore errors - not critical
197
+ }
198
+ }
199
+
200
+ // Restore original console methods
201
+ console.log = this.originalConsoleLog;
202
+ console.error = this.originalConsoleError;
203
+ }
204
+
205
+ loadPackageInfo() {
206
+ try {
207
+ const packageJson = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8');
208
+ const packageInfo = JSON.parse(packageJson);
209
+
210
+ if (!packageInfo.binaries || !packageInfo.binaries.files) {
211
+ throw new Error('Binary information missing in package configuration');
212
+ }
213
+
214
+ return packageInfo;
215
+ } catch (error) {
216
+ throw new Error(`Unable to read package configuration: ${error.message}`);
217
+ }
218
+ }
219
+
220
+ findBinaryInfo() {
221
+ const files = this.packageInfo.binaries.files;
222
+ const targetFile = files.find(
223
+ (file) => file.os === this.platform && file.arch === this.arch,
224
+ );
225
+
226
+ if (!targetFile) {
227
+ throw new Error(`Unsupported platform: ${this.platform}/${this.arch}`);
228
+ }
229
+
230
+ return targetFile;
231
+ }
232
+
233
+ async downloadBinary(url, expectedSha256) {
234
+ console.log(`Downloading binary: ${url}`);
235
+
236
+ // Create temporary directory for download operations
237
+ const os = require('os');
238
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qodercli-install-'));
239
+
240
+ // Ensure target directory exists
241
+ if (!fs.existsSync(BIN_DIR)) {
242
+ fs.mkdirSync(BIN_DIR, { recursive: true });
243
+ }
244
+
245
+ // Download file to temporary directory
246
+ const filename = path.basename(url);
247
+ const archivePath = path.join(tempDir, filename);
248
+
249
+ try {
250
+ await this.downloadFile(url, archivePath);
251
+
252
+ // Verify checksum
253
+ console.log('Verifying file integrity...');
254
+ const actualSha256 = this.calculateSha256(archivePath);
255
+ if (actualSha256 !== expectedSha256) {
256
+ throw new Error(
257
+ `Checksum mismatch. Expected: ${expectedSha256}, Got: ${actualSha256}`,
258
+ );
259
+ }
260
+
261
+ // Extract file to temporary directory first
262
+ console.log('Extracting binary...');
263
+ const extractDir = path.join(tempDir, 'extract');
264
+ fs.mkdirSync(extractDir, { recursive: true });
265
+ await this.extractArchive(archivePath, filename, extractDir);
266
+
267
+ // Move extracted binary to final destination
268
+ const extractedBinary = this.findExtractedBinary(extractDir);
269
+ if (extractedBinary.length === 0) {
270
+ throw new Error(
271
+ `Binary file not found after extraction in ${extractDir}`,
272
+ );
273
+ }
274
+
275
+ // Try rename first (efficient), fallback to copy+delete if cross-device
276
+ try {
277
+ fs.renameSync(extractedBinary[0], this.binPath);
278
+ } catch (error) {
279
+ if (error.code === 'EXDEV') {
280
+ // Cross-device link not permitted, use copy+delete fallback
281
+ console.log(
282
+ 'Cross-device link detected, using copy+delete method...',
283
+ );
284
+ fs.copyFileSync(extractedBinary[0], this.binPath);
285
+ fs.unlinkSync(extractedBinary[0]);
286
+ } else {
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ // Set executable permission
292
+ if (process.platform !== 'win32') {
293
+ fs.chmodSync(this.binPath, 0o755);
294
+ }
295
+
296
+ // Create installation source marker
297
+ const sourceFile = path.join(BIN_DIR, '.qodercli-install-resource');
298
+ fs.writeFileSync(sourceFile, 'npm', 'utf8');
299
+ } catch (error) {
300
+ throw error;
301
+ } finally {
302
+ // Always cleanup temporary directory
303
+ try {
304
+ fs.rmSync(tempDir, { recursive: true, force: true });
305
+ } catch (cleanupError) {
306
+ console.warn(
307
+ 'Warning: Failed to cleanup temporary directory:',
308
+ cleanupError.message,
309
+ );
310
+ }
311
+ }
312
+ }
313
+
314
+ async extractArchive(archivePath, filename, extractDir) {
315
+ if (filename.endsWith('.zip')) {
316
+ // Extract ZIP file using Node.js packages first
317
+ let extracted = false;
318
+
319
+ // Method 1: Use adm-zip package (preferred)
320
+ try {
321
+ const AdmZip = require('adm-zip');
322
+ const zip = new AdmZip(archivePath);
323
+ zip.extractAllTo(extractDir, true);
324
+ extracted = true;
325
+ console.log('ZIP extracted using Node.js adm-zip package');
326
+ } catch (error) {
327
+ console.log(
328
+ 'adm-zip extraction failed, trying system commands...',
329
+ error.message,
330
+ );
331
+ }
332
+
333
+ // Method 2: System command fallbacks
334
+ if (!extracted) {
335
+ if (process.platform === 'win32') {
336
+ // Windows: Try PowerShell then 7-Zip
337
+ try {
338
+ execSync(
339
+ `powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`,
340
+ {
341
+ stdio: 'pipe',
342
+ },
343
+ );
344
+ extracted = true;
345
+ } catch (error) {
346
+ try {
347
+ execSync(`7z x "${archivePath}" -o"${extractDir}" -y`, {
348
+ stdio: 'pipe',
349
+ });
350
+ extracted = true;
351
+ } catch (error2) {
352
+ // Will fail below
353
+ }
354
+ }
355
+ } else {
356
+ // Unix: Use unzip command
357
+ try {
358
+ execSync(`unzip -o "${archivePath}" -d "${extractDir}"`, {
359
+ stdio: 'pipe',
360
+ });
361
+ extracted = true;
362
+ } catch (error) {
363
+ // Will fail below
364
+ }
365
+ }
366
+ }
367
+
368
+ if (!extracted) {
369
+ const platform = process.platform === 'win32' ? 'Windows' : 'Unix';
370
+ throw new Error(
371
+ `ZIP extraction failed on ${platform}. Please ensure extraction tools are available.`,
372
+ );
373
+ }
374
+ } else {
375
+ // Extract tar.gz file using Node.js tar package first
376
+ let extracted = false;
377
+
378
+ // Method 1: Use tar package (preferred)
379
+ try {
380
+ const tar = require('tar');
381
+ // tar v4.x uses different API than v6.x
382
+ await tar.extract({
383
+ file: archivePath,
384
+ cwd: extractDir,
385
+ });
386
+ extracted = true;
387
+ console.log('tar.gz extracted using Node.js tar package');
388
+ } catch (error) {
389
+ console.log(
390
+ 'Node.js tar extraction failed, trying system tar command...',
391
+ error.message,
392
+ );
393
+ }
394
+
395
+ // Method 2: System tar command fallback
396
+ if (!extracted) {
397
+ try {
398
+ execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, {
399
+ stdio: 'pipe',
400
+ });
401
+ extracted = true;
402
+ } catch (error) {
403
+ throw new Error(
404
+ 'tar.gz extraction failed. Please ensure tar command is installed.',
405
+ );
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ calculateSha256(filePath) {
412
+ const fileBuffer = fs.readFileSync(filePath);
413
+ const hashSum = crypto.createHash('sha256');
414
+ hashSum.update(fileBuffer);
415
+ return hashSum.digest('hex');
416
+ }
417
+
418
+ findExtractedBinary(searchDir) {
419
+ const results = [];
420
+ const expectedFilename =
421
+ BINARY_NAME + (process.platform === 'win32' ? '.exe' : '');
422
+
423
+ try {
424
+ const items = fs.readdirSync(searchDir, { withFileTypes: true });
425
+
426
+ for (const item of items) {
427
+ const fullPath = path.join(searchDir, item.name);
428
+
429
+ if (item.isDirectory()) {
430
+ // Recursively search in subdirectories
431
+ results.push(...this.findExtractedBinary(fullPath));
432
+ } else if (item.name === expectedFilename) {
433
+ results.push(fullPath);
434
+ }
435
+ }
436
+ } catch (error) {
437
+ console.warn(`Unable to search directory ${searchDir}:`, error.message);
438
+ }
439
+
440
+ return results;
441
+ }
442
+
443
+ verifyInstallation() {
444
+ if (!fs.existsSync(this.binPath)) {
445
+ throw new Error('Binary installation failed');
446
+ }
447
+ if (this.logStream?.fd !== undefined) {
448
+ try {
449
+ fs.fsyncSync(this.logStream.fd);
450
+ } catch (e) {}
451
+ }
452
+
453
+ try {
454
+ const output = execSync(`"${this.binPath}" --version`, {
455
+ encoding: 'utf8',
456
+ stdio: 'pipe',
457
+ env: { ...process.env, QODER_CLI_INSTALL: '1' },
458
+ });
459
+ const versionInfo = output.trim();
460
+ console.log('Installation verified successfully');
461
+ return versionInfo;
462
+ } catch (error) {
463
+ console.warn(
464
+ 'Warning: Unable to verify installation, but binary file exists',
465
+ );
466
+ return null;
467
+ }
468
+ }
469
+
470
+ async downloadFile(url, filePath, timeout = 60000) {
471
+ return new Promise((resolve, reject) => {
472
+ const file = fs.createWriteStream(filePath);
473
+ const client = url.startsWith('https:') ? https : http;
474
+ let cleanupDone = false;
475
+
476
+ const cleanup = () => {
477
+ if (cleanupDone) return;
478
+ cleanupDone = true;
479
+
480
+ try {
481
+ file.close();
482
+ } catch (e) {
483
+ // Ignore errors during cleanup
484
+ }
485
+
486
+ try {
487
+ if (fs.existsSync(filePath)) {
488
+ fs.unlinkSync(filePath);
489
+ }
490
+ } catch (e) {
491
+ // Ignore errors during cleanup
492
+ }
493
+ };
494
+ const parsedUrl = new URL(url);
495
+ const options = {
496
+ hostname: parsedUrl.hostname,
497
+ port: parsedUrl.port,
498
+ path: parsedUrl.pathname + parsedUrl.search,
499
+ headers: {
500
+ 'User-Agent': 'qodercli-installer/npm (https://qoder.com)',
501
+ },
502
+ };
503
+
504
+ const request = client
505
+ .get(options, (response) => {
506
+ if (response.statusCode === 302 || response.statusCode === 301) {
507
+ // Handle redirect
508
+ cleanup();
509
+ return this.downloadFile(
510
+ response.headers.location,
511
+ filePath,
512
+ timeout,
513
+ )
514
+ .then(resolve)
515
+ .catch(reject);
516
+ }
517
+
518
+ if (response.statusCode !== 200) {
519
+ cleanup();
520
+ reject(
521
+ new Error(
522
+ `HTTP ${response.statusCode}: ${response.statusMessage}`,
523
+ ),
524
+ );
525
+ return;
526
+ }
527
+
528
+ response.pipe(file);
529
+
530
+ file.on('finish', () => {
531
+ if (!cleanupDone) {
532
+ file.close();
533
+ resolve();
534
+ }
535
+ });
536
+
537
+ file.on('error', (error) => {
538
+ cleanup();
539
+ reject(error);
540
+ });
541
+ })
542
+ .on('error', (error) => {
543
+ cleanup();
544
+ reject(error);
545
+ });
546
+
547
+ // Set timeout
548
+ request.setTimeout(timeout, () => {
549
+ request.destroy();
550
+ cleanup();
551
+ reject(new Error(`Download timeout (${timeout}ms): ${url}`));
552
+ });
553
+
554
+ // Handle process interruption signals
555
+ const handleSignal = () => {
556
+ request.destroy();
557
+ cleanup();
558
+ reject(new Error('Download interrupted by signal'));
559
+ };
560
+
561
+ process.once('SIGINT', handleSignal);
562
+ process.once('SIGTERM', handleSignal);
563
+
564
+ // Clean up signal handlers when promise resolves/rejects
565
+ const originalResolve = resolve;
566
+ const originalReject = reject;
567
+
568
+ resolve = (...args) => {
569
+ process.removeListener('SIGINT', handleSignal);
570
+ process.removeListener('SIGTERM', handleSignal);
571
+ originalResolve(...args);
572
+ };
573
+
574
+ reject = (...args) => {
575
+ process.removeListener('SIGINT', handleSignal);
576
+ process.removeListener('SIGTERM', handleSignal);
577
+ originalReject(...args);
578
+ };
579
+ });
580
+ }
581
+
582
+ async install() {
583
+ let installSuccess = false;
584
+ let versionInfo = null;
585
+
586
+ try {
587
+ console.log('Installing Qoder CLI...');
588
+ console.log(`Target platform: ${this.platform}/${this.arch}`);
589
+ console.log(`Version: ${this.packageInfo.binaries.version}`);
590
+
591
+ // If already installed, reinstall
592
+ if (fs.existsSync(this.binPath)) {
593
+ console.log('Existing version detected, will reinstall');
594
+ }
595
+
596
+ const binaryInfo = this.findBinaryInfo();
597
+ await this.downloadBinary(binaryInfo.url, binaryInfo.sha256);
598
+
599
+ // Verify and get version info
600
+ versionInfo = this.verifyInstallation();
601
+ installSuccess = true;
602
+ } catch (error) {
603
+ console.error('');
604
+ console.error('Installation failed:', error.message);
605
+ console.error('');
606
+ if (this.logFile) {
607
+ console.error(`Installation log: ${this.logFile}`);
608
+ console.error('');
609
+ }
610
+ console.error('For help, visit: https://forum.qoder.com/c/bug-reports');
611
+ console.error('');
612
+
613
+ this.closeLogging();
614
+ process.exit(1);
615
+ } finally {
616
+ // Show final summary
617
+ if (installSuccess) {
618
+ this.showSuccessSummary(versionInfo);
619
+ }
620
+
621
+ // Close logging
622
+ this.closeLogging();
623
+ }
624
+ }
625
+
626
+ showSuccessSummary(versionInfo) {
627
+ console.log('');
628
+
629
+ if (versionInfo) {
630
+ console.log(`Qoder CLI ${versionInfo} installed successfully!`);
631
+ } else {
632
+ console.log('Qoder CLI installed successfully!');
633
+ }
634
+
635
+ console.log('');
636
+ console.log('Get started: qodercli --help');
637
+ console.log('Need help? Visit: https://forum.qoder.com/c/bug-reports');
638
+ console.log('');
639
+ }
640
+ }
641
+
642
+ // Main program
643
+ if (require.main === module) {
644
+ let installer = null;
645
+ try {
646
+ installer = new QoderInstaller();
647
+ installer.install();
648
+ } catch (error) {
649
+ console.error('');
650
+ console.error('Failed to initialize installer:', error.message);
651
+ console.error('');
652
+ console.error('This might be due to Node.js version compatibility issues.');
653
+ console.error(`Current Node.js version: ${process.version}`);
654
+ console.error('Required Node.js version: >=14');
655
+ console.error('');
656
+
657
+ // Show log file location if logging was initialized
658
+ // (now possible since setupLogging() runs first)
659
+ if (installer && installer.logFile) {
660
+ console.error(`Installation log: ${installer.logFile}`);
661
+ console.error('');
662
+ }
663
+
664
+ console.error('For help, visit: https://forum.qoder.com/c/bug-reports');
665
+ console.error('');
666
+ process.exit(1);
667
+ }
668
+ }
669
+
670
+ module.exports = QoderInstaller;