@openbuff/cli 1.9.30 → 1.9.31

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/index.js +243 -172
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -213,11 +213,35 @@ function getPostHogConfig() {
213
213
  return { apiKey, host }
214
214
  }
215
215
 
216
+ const UPDATE_ERROR_CATEGORIES = new Set([
217
+ 'platform_check',
218
+ 'checksum_manifest',
219
+ 'http_download',
220
+ 'checksum_verify',
221
+ 'extraction',
222
+ ])
223
+
224
+ function getUpdateFailureProperties(version, context = {}) {
225
+ const category = UPDATE_ERROR_CATEGORIES.has(context.stage)
226
+ ? context.stage
227
+ : 'unknown'
228
+ return {
229
+ distinct_id: 'anonymous-openbuff-release',
230
+ error: category,
231
+ version: version || 'unknown',
232
+ platform: process.platform,
233
+ arch: process.arch,
234
+ ...(category === 'http_download' && Number.isInteger(context.statusCode)
235
+ ? { statusCode: context.statusCode }
236
+ : {}),
237
+ }
238
+ }
239
+
216
240
  /**
217
241
  * Track update failure event to PostHog.
218
242
  * Fire-and-forget - errors are silently ignored.
219
243
  */
220
- function trackUpdateFailed(errorMessage, version, context = {}) {
244
+ function trackUpdateFailed(_errorMessage, version, context = {}) {
221
245
  try {
222
246
  const posthogConfig = getPostHogConfig()
223
247
  if (!posthogConfig) {
@@ -227,14 +251,7 @@ function trackUpdateFailed(errorMessage, version, context = {}) {
227
251
  const payload = JSON.stringify({
228
252
  api_key: posthogConfig.apiKey,
229
253
  event: 'cli.update_openbuff_failed',
230
- properties: {
231
- distinct_id: `anonymous-${CONFIG.homeDir}`,
232
- error: errorMessage,
233
- version: version || 'unknown',
234
- platform: process.platform,
235
- arch: process.arch,
236
- ...context,
237
- },
254
+ properties: getUpdateFailureProperties(version, context),
238
255
  timestamp: new Date().toISOString(),
239
256
  })
240
257
 
@@ -625,21 +642,24 @@ function compareVersions(v1, v2) {
625
642
  i < Math.max(p1.prerelease.length, p2.prerelease.length);
626
643
  i++
627
644
  ) {
628
- const pr1 = p1.prerelease[i] || ''
629
- const pr2 = p2.prerelease[i] || ''
645
+ if (i >= p1.prerelease.length) return -1
646
+ if (i >= p2.prerelease.length) return 1
647
+
648
+ const pr1 = p1.prerelease[i]
649
+ const pr2 = p2.prerelease[i]
630
650
 
631
- const isNum1 = !isNaN(parseInt(pr1))
632
- const isNum2 = !isNaN(parseInt(pr2))
651
+ const isNum1 = /^\d+$/.test(pr1)
652
+ const isNum2 = /^\d+$/.test(pr2)
633
653
 
634
654
  if (isNum1 && isNum2) {
635
- const num1 = parseInt(pr1)
636
- const num2 = parseInt(pr2)
655
+ const num1 = Number(pr1)
656
+ const num2 = Number(pr2)
637
657
  if (num1 < num2) return -1
638
658
  if (num1 > num2) return 1
639
659
  } else if (isNum1 && !isNum2) {
640
- return 1
641
- } else if (!isNum1 && isNum2) {
642
660
  return -1
661
+ } else if (!isNum1 && isNum2) {
662
+ return 1
643
663
  } else if (pr1 < pr2) {
644
664
  return -1
645
665
  } else if (pr1 > pr2) {
@@ -671,8 +691,8 @@ function getReleaseAssetBase(version) {
671
691
  return `${downloadBase}/v${version}`
672
692
  }
673
693
 
674
- async function getExpectedChecksum(version, fileName) {
675
- const checksumResponse = await httpGet(
694
+ async function getExpectedChecksum(version, fileName, httpGetFn = httpGet) {
695
+ const checksumResponse = await httpGetFn(
676
696
  `${getReleaseAssetBase(version)}/SHA256SUMS`,
677
697
  )
678
698
  if (checksumResponse.statusCode !== 200) {
@@ -733,8 +753,35 @@ function downloadResponseToFile(response, destination, totalSize) {
733
753
  })
734
754
  }
735
755
 
736
- async function downloadBinary(version) {
737
- const platformKey = getPlatformKey()
756
+ function assertExtractedRegularFile(extractionDir, filePath) {
757
+ const root = path.resolve(extractionDir)
758
+ const resolved = path.resolve(filePath)
759
+ if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) {
760
+ throw new Error(`Release archive entry escapes extraction directory: ${filePath}`)
761
+ }
762
+
763
+ let stat
764
+ try {
765
+ stat = fs.lstatSync(resolved)
766
+ } catch (error) {
767
+ if (error.code === 'ENOENT') return false
768
+ throw error
769
+ }
770
+ if (!stat.isFile()) {
771
+ throw new Error(
772
+ `Release archive entry must be a regular file: ${path.basename(filePath)}`,
773
+ )
774
+ }
775
+ return true
776
+ }
777
+
778
+ async function downloadBinary(version, options = {}) {
779
+ const config = options.config || CONFIG
780
+ const httpGetFn = options.httpGet || httpGet
781
+ const extractArchive =
782
+ options.extractArchive || ((tarOptions) => require('tar').x(tarOptions))
783
+ const rename = options.rename || fs.renameSync
784
+ const platformKey = options.platformKey || getPlatformKey()
738
785
  const fileName = PLATFORM_TARGETS[platformKey]
739
786
 
740
787
  if (!fileName) {
@@ -748,160 +795,186 @@ async function downloadBinary(version) {
748
795
  throw error
749
796
  }
750
797
 
751
- // Binaries are hosted as GitHub Release assets on the public repo.
752
- // Public repo → unauthenticated downloads; GitHub 302-redirects to a CDN,
753
- // and the http.js client follows redirects. OPENBUFF_DOWNLOAD_BASE may
754
- // override the base (e.g. for staging mirrors).
755
798
  const downloadUrl = `${getReleaseAssetBase(version)}/${fileName}`
756
-
757
- // Ensure config directory exists
758
- fs.mkdirSync(CONFIG.configDir, { recursive: true })
759
-
760
- // Clean up any previous temp download directory
761
- if (fs.existsSync(CONFIG.tempDownloadDir)) {
762
- fs.rmSync(CONFIG.tempDownloadDir, { recursive: true })
799
+ fs.mkdirSync(config.configDir, { recursive: true })
800
+ if (fs.existsSync(config.tempDownloadDir)) {
801
+ fs.rmSync(config.tempDownloadDir, { recursive: true })
763
802
  }
764
- fs.mkdirSync(CONFIG.tempDownloadDir, { recursive: true })
765
-
803
+ fs.mkdirSync(config.tempDownloadDir, { recursive: true })
766
804
  term.write('Downloading...')
767
805
 
768
- let expectedChecksum
769
806
  try {
770
- expectedChecksum = await getExpectedChecksum(version, fileName)
771
- } catch (error) {
772
- fs.rmSync(CONFIG.tempDownloadDir, { recursive: true })
773
- trackUpdateFailed(error.message, version, { stage: 'checksum_manifest' })
774
- throw error
775
- }
807
+ let expectedChecksum
808
+ try {
809
+ expectedChecksum = await getExpectedChecksum(
810
+ version,
811
+ fileName,
812
+ httpGetFn,
813
+ )
814
+ } catch (error) {
815
+ trackUpdateFailed(error.message, version, { stage: 'checksum_manifest' })
816
+ throw error
817
+ }
776
818
 
777
- const res = await httpGet(downloadUrl)
819
+ const res = await httpGetFn(downloadUrl)
820
+ if (res.statusCode !== 200) {
821
+ res.resume()
822
+ const error = new Error(`Download failed: HTTP ${res.statusCode}`)
823
+ trackUpdateFailed(error.message, version, {
824
+ stage: 'http_download',
825
+ statusCode: res.statusCode,
826
+ })
827
+ throw error
828
+ }
778
829
 
779
- if (res.statusCode !== 200) {
780
- fs.rmSync(CONFIG.tempDownloadDir, { recursive: true })
781
- const error = new Error(`Download failed: HTTP ${res.statusCode}`)
782
- trackUpdateFailed(error.message, version, {
783
- stage: 'http_download',
784
- statusCode: res.statusCode,
830
+ const totalSize = parseInt(res.headers['content-length'] || '0', 10)
831
+ const archivePath = path.join(config.tempDownloadDir, fileName)
832
+ await downloadResponseToFile(res, archivePath, totalSize)
833
+
834
+ const actualChecksum = await hashFile(archivePath)
835
+ if (actualChecksum !== expectedChecksum) {
836
+ const error = new Error(
837
+ `Checksum verification failed for ${fileName}: expected ${expectedChecksum}, received ${actualChecksum}`,
838
+ )
839
+ trackUpdateFailed(error.message, version, { stage: 'checksum_verify' })
840
+ throw error
841
+ }
842
+
843
+ await extractArchive({
844
+ cwd: config.tempDownloadDir,
845
+ file: archivePath,
846
+ preservePaths: false,
847
+ strict: true,
785
848
  })
786
- throw error
787
- }
788
849
 
789
- const totalSize = parseInt(res.headers['content-length'] || '0', 10)
790
- const archivePath = path.join(CONFIG.tempDownloadDir, fileName)
791
- await downloadResponseToFile(res, archivePath, totalSize)
850
+ const tempBinaryPath = path.join(config.tempDownloadDir, config.binaryName)
851
+ if (!assertExtractedRegularFile(config.tempDownloadDir, tempBinaryPath)) {
852
+ const files = fs.readdirSync(config.tempDownloadDir)
853
+ const error = new Error(
854
+ `Binary not found after extraction. Expected: ${config.binaryName}, Available files: ${files.join(', ')}`,
855
+ )
856
+ trackUpdateFailed(error.message, version, { stage: 'extraction' })
857
+ throw error
858
+ }
792
859
 
793
- const actualChecksum = await hashFile(archivePath)
794
- if (actualChecksum !== expectedChecksum) {
795
- fs.rmSync(CONFIG.tempDownloadDir, { recursive: true })
796
- const error = new Error(
797
- `Checksum verification failed for ${fileName}: expected ${expectedChecksum}, received ${actualChecksum}`,
860
+ const managedSiblings = getManagedSiblingNames(config.tempDownloadDir)
861
+ .map((name) => ({
862
+ name,
863
+ source: path.join(config.tempDownloadDir, name),
864
+ target: path.join(path.dirname(config.binaryPath), name),
865
+ }))
866
+ .filter(({ source }) =>
867
+ assertExtractedRegularFile(config.tempDownloadDir, source),
868
+ )
869
+ const tempMetadataPath = path.join(
870
+ config.tempDownloadDir,
871
+ path.basename(config.metadataPath),
798
872
  )
799
- trackUpdateFailed(error.message, version, { stage: 'checksum_verify' })
800
- throw error
801
- }
802
-
803
- // Extract only after the archive has passed integrity verification.
804
- const tar = require('tar')
805
- await tar.x({
806
- cwd: CONFIG.tempDownloadDir,
807
- file: archivePath,
808
- preservePaths: false,
809
- strict: true,
810
- })
873
+ assertExtractedRegularFile(config.tempDownloadDir, tempMetadataPath)
811
874
 
812
- const extractedAssetProblems = getTreeSitterAssetProblems(
813
- CONFIG.tempDownloadDir,
814
- )
815
- if (extractedAssetProblems.length) {
816
- throw new Error(
817
- `Release archive has incomplete tree-sitter assets: ${extractedAssetProblems.join(', ')}`,
875
+ const extractedAssetProblems = getTreeSitterAssetProblems(
876
+ config.tempDownloadDir,
818
877
  )
819
- }
878
+ if (extractedAssetProblems.length) {
879
+ throw new Error(
880
+ `Release archive has incomplete tree-sitter assets: ${extractedAssetProblems.join(', ')}`,
881
+ )
882
+ }
820
883
 
821
- const tempBinaryPath = path.join(CONFIG.tempDownloadDir, CONFIG.binaryName)
884
+ if (process.platform !== 'win32') fs.chmodSync(tempBinaryPath, 0o755)
822
885
 
823
- // Verify the binary was extracted
824
- if (!fs.existsSync(tempBinaryPath)) {
825
- const files = fs.readdirSync(CONFIG.tempDownloadDir)
826
- fs.rmSync(CONFIG.tempDownloadDir, { recursive: true })
827
- const error = new Error(
828
- `Binary not found after extraction. Expected: ${CONFIG.binaryName}, Available files: ${files.join(', ')}`,
886
+ const installFiles = [
887
+ { source: tempBinaryPath, target: config.binaryPath },
888
+ ...managedSiblings,
889
+ ]
890
+ fs.writeFileSync(
891
+ tempMetadataPath,
892
+ JSON.stringify({ version, platformKey }, null, 2),
829
893
  )
830
- trackUpdateFailed(error.message, version, { stage: 'extraction' })
831
- throw error
832
- }
894
+ installFiles.push({ source: tempMetadataPath, target: config.metadataPath })
833
895
 
834
- // Set executable permissions
835
- if (process.platform !== 'win32') {
836
- fs.chmodSync(tempBinaryPath, 0o755)
837
- }
896
+ for (const file of installFiles) {
897
+ if (process.platform !== 'win32' && file.name === 'rg') {
898
+ fs.chmodSync(file.source, 0o755)
899
+ }
900
+ }
838
901
 
839
- // Move binary to final location
840
- try {
841
- if (fs.existsSync(CONFIG.binaryPath)) {
842
- try {
843
- fs.unlinkSync(CONFIG.binaryPath)
844
- } catch (err) {
845
- // Fallback: try renaming the locked/undeletable binary (Windows)
846
- const backupPath = CONFIG.binaryPath + `.old.${Date.now()}`
902
+ const committed = []
903
+ try {
904
+ for (const [index, file] of installFiles.entries()) {
905
+ const backup = `${file.target}.rollback-${process.pid}-${index}`
906
+ const hadExisting = fs.existsSync(file.target)
907
+ if (hadExisting) rename(file.target, backup)
847
908
  try {
848
- fs.renameSync(CONFIG.binaryPath, backupPath)
849
- } catch (renameErr) {
850
- throw new Error(
851
- `Failed to replace existing binary. ` +
852
- `unlink error: ${err.code || err.message}, ` +
853
- `rename error: ${renameErr.code || renameErr.message}`,
854
- )
909
+ rename(file.source, file.target)
910
+ } catch (error) {
911
+ if (hadExisting && fs.existsSync(backup)) rename(backup, file.target)
912
+ throw error
855
913
  }
914
+ committed.push({ ...file, backup: hadExisting ? backup : null })
856
915
  }
916
+ } catch (error) {
917
+ for (const file of committed.reverse()) {
918
+ if (fs.existsSync(file.target)) fs.unlinkSync(file.target)
919
+ if (file.backup && fs.existsSync(file.backup)) {
920
+ fs.renameSync(file.backup, file.target)
921
+ }
922
+ }
923
+ throw error
857
924
  }
858
- fs.renameSync(tempBinaryPath, CONFIG.binaryPath)
859
-
860
- // Preserve every managed sibling extracted from the release archive.
861
- // This includes the parser runtime, all language grammar WASMs, and
862
- // legacy native/ripgrep assets. Deriving the grammar list from the
863
- // archive keeps the installer aligned with the build manifest.
864
- for (const siblingName of getManagedSiblingNames(CONFIG.tempDownloadDir)) {
865
- const tempSiblingPath = path.join(CONFIG.tempDownloadDir, siblingName)
866
- if (!fs.existsSync(tempSiblingPath)) continue
867
- const targetSiblingPath = path.join(
868
- path.dirname(CONFIG.binaryPath),
869
- siblingName,
870
- )
925
+
926
+ for (const file of committed) {
927
+ if (!file.backup) continue
871
928
  try {
872
- if (fs.existsSync(targetSiblingPath)) fs.unlinkSync(targetSiblingPath)
929
+ fs.unlinkSync(file.backup)
873
930
  } catch {
874
- // best effort; rename below will surface the real error if it matters
875
- }
876
- fs.renameSync(tempSiblingPath, targetSiblingPath)
877
- if (process.platform !== 'win32' && siblingName === 'rg') {
878
- fs.chmodSync(targetSiblingPath, 0o755)
931
+ // A stale backup is harmless and can be removed on a later launch.
879
932
  }
880
933
  }
881
934
 
882
- // Save version metadata for fast version checking
883
- fs.writeFileSync(
884
- CONFIG.metadataPath,
885
- JSON.stringify({ version, platformKey }, null, 2),
886
- )
935
+ term.clearLine()
936
+ console.log('Download complete! Starting Openbuff...')
887
937
  } finally {
888
- // Clean up temp directory even if rename fails
889
- if (fs.existsSync(CONFIG.tempDownloadDir)) {
890
- fs.rmSync(CONFIG.tempDownloadDir, { recursive: true })
938
+ if (fs.existsSync(config.tempDownloadDir)) {
939
+ fs.rmSync(config.tempDownloadDir, { recursive: true })
891
940
  }
892
941
  }
942
+ }
893
943
 
894
- term.clearLine()
895
- console.log('Download complete! Starting Openbuff...')
944
+ function printInstallFailureGuidance(resolveProxyUrl, logError) {
945
+ logError('Please check your internet connection and try again')
946
+ if (!resolveProxyUrl()) {
947
+ logError('If you are behind a proxy, set the HTTPS_PROXY environment variable')
948
+ }
896
949
  }
897
950
 
898
- async function ensureBinaryExists() {
899
- const currentVersion = getCurrentVersion()
951
+ async function ensureBinaryExists(options = {}) {
952
+ const config = options.config || CONFIG
953
+ const logError = options.consoleError || console.error
954
+ const resolveProxyUrl = options.getProxyUrl || getProxyUrl
955
+ const exit = options.exit || process.exit
956
+ const currentVersion =
957
+ options.currentVersion === undefined
958
+ ? getCurrentVersion()
959
+ : options.currentVersion
900
960
  const assetProblems = currentVersion
901
- ? getTreeSitterAssetProblems(CONFIG.configDir)
961
+ ? getTreeSitterAssetProblems(config.configDir)
902
962
  : []
903
- const pendingVersion = getPendingUpdateVersion()
904
- const packagedVersion = getLocalPackageVersion()
963
+ let pendingVersion = options.pendingVersion
964
+ if (pendingVersion === undefined) {
965
+ try {
966
+ pendingVersion = fs.existsSync(config.metadataPath)
967
+ ? JSON.parse(fs.readFileSync(config.metadataPath, 'utf8'))
968
+ .pendingVersion || null
969
+ : null
970
+ } catch {
971
+ pendingVersion = null
972
+ }
973
+ }
974
+ const packagedVersion =
975
+ options.packagedVersion === undefined
976
+ ? getLocalPackageVersion()
977
+ : options.packagedVersion
905
978
  const packagedUpdate =
906
979
  packagedVersion &&
907
980
  (currentVersion === null ||
@@ -913,60 +986,54 @@ async function ensureBinaryExists() {
913
986
  packagedUpdate ||
914
987
  (assetProblems.length ? currentVersion : null)
915
988
 
916
- if (currentVersion !== null && !requestedVersion) {
917
- return
918
- }
989
+ if (currentVersion !== null && !requestedVersion) return
919
990
 
920
991
  if (assetProblems.length) {
921
- console.error(
992
+ logError(
922
993
  `Repairing incomplete tree-sitter assets: ${assetProblems.join(', ')}`,
923
994
  )
924
995
  }
925
996
 
926
- const version = requestedVersion || (await getLatestVersion())
997
+ const version =
998
+ requestedVersion ||
999
+ (await (options.getLatestVersion || getLatestVersion)())
927
1000
  if (!version) {
928
- console.error('❌ Failed to determine latest version')
929
- console.error('Please check your internet connection and try again')
930
- if (!getProxyUrl()) {
931
- console.error(
932
- 'If you are behind a proxy, set the HTTPS_PROXY environment variable',
933
- )
934
- }
935
- process.exit(1)
1001
+ logError('❌ Failed to determine latest version')
1002
+ printInstallFailureGuidance(resolveProxyUrl, logError)
1003
+ exit(1)
936
1004
  }
937
1005
 
1006
+ const download =
1007
+ options.downloadBinary ||
1008
+ ((requestedVersion) => downloadBinary(requestedVersion, { config }))
938
1009
  try {
939
- await downloadBinary(version)
1010
+ await download(version)
940
1011
  } catch (error) {
941
1012
  term.clearLine()
942
- console.error('❌ Failed to download openbuff:', error.message)
943
- console.error('Please check your internet connection and try again')
944
- if (!getProxyUrl()) {
945
- console.error(
946
- 'If you are behind a proxy, set the HTTPS_PROXY environment variable',
947
- )
948
- }
949
- process.exit(1)
1013
+ logError('❌ Failed to download openbuff:', error.message)
1014
+ printInstallFailureGuidance(resolveProxyUrl, logError)
1015
+ exit(1)
950
1016
  }
951
1017
  }
952
1018
 
953
- async function checkForUpdates() {
1019
+ async function checkForUpdates(options = {}) {
954
1020
  try {
955
- const currentVersion = getCurrentVersion()
956
-
957
- const latestVersion = await getLatestVersion()
1021
+ const currentVersion =
1022
+ options.currentVersion === undefined
1023
+ ? getCurrentVersion()
1024
+ : options.currentVersion
1025
+ const latestVersion = await (
1026
+ options.getLatestVersion || getLatestVersion
1027
+ )()
958
1028
  if (!latestVersion) return
959
1029
 
960
1030
  if (
961
- // Download new version if current version is unknown or outdated.
962
1031
  currentVersion === null ||
963
1032
  compareVersions(currentVersion, latestVersion) < 0
964
1033
  ) {
965
- term.clearLine()
966
- console.error(
967
- `Openbuff update available: ${currentVersion ?? 'unknown'} → ${latestVersion}. It will be installed on the next launch.`,
968
- )
969
- writePendingUpdateVersion(latestVersion)
1034
+ const persistPending =
1035
+ options.writePendingUpdateVersion || writePendingUpdateVersion
1036
+ persistPending(latestVersion)
970
1037
  }
971
1038
  } catch (error) {
972
1039
  trackUpdateFailed(error.message, null, { stage: 'background_check' })
@@ -1082,11 +1149,15 @@ if (require.main === module) {
1082
1149
  }
1083
1150
 
1084
1151
  module.exports = {
1152
+ checkForUpdates,
1085
1153
  cleanupOldBinaryBackups,
1086
1154
  compareVersions,
1155
+ downloadBinary,
1156
+ ensureBinaryExists,
1087
1157
  getIllegalInstructionGuidance,
1088
1158
  getManagedSiblingNames,
1089
1159
  getTreeSitterAssetProblems,
1160
+ getUpdateFailureProperties,
1090
1161
  parseExpectedChecksum,
1091
1162
  parseLinuxCpuInfo,
1092
1163
  resolveConfigDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbuff/cli",
3
- "version": "1.9.30",
3
+ "version": "1.9.31",
4
4
  "description": "An AI coding agent that writes code for you. Run it from your project directory, tell it what to do, and it reads and writes files, runs commands, and iterates until the task is done. Supports sub-agents, custom tools, and Bring-Your-Own-Key providers.",
5
5
  "license": "MIT",
6
6
  "keywords": [