freebuff 0.2.10 → 0.2.11

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/launcher.js +225 -9
  2. package/package.json +9 -9
package/launcher.js CHANGED
@@ -1336,10 +1336,17 @@ function createLauncher(productConfig) {
1336
1336
 
1337
1337
  function installStagedBinary(
1338
1338
  { tempBinaryPath, version, targetKey },
1339
- { quiet = false } = {},
1339
+ // keepStagingOnFailure: leave a staged update that could not be moved
1340
+ // into place (a file locked by antivirus or another session on Windows)
1341
+ // for the next launch to adopt, instead of downloading it all again.
1342
+ { quiet = false, keepStagingOnFailure = false } = {},
1340
1343
  ) {
1341
1344
  const replacements = []
1345
+ let installed = false
1342
1346
  const metadataTempPath = `${CONFIG.metadataPath}.new.${process.pid}`
1347
+ // The directory the binary was staged in: this launcher's own, or one
1348
+ // adopted from a launcher that died before it could install it.
1349
+ const stagingDir = path.dirname(tempBinaryPath)
1343
1350
 
1344
1351
  try {
1345
1352
  fs.writeFileSync(
@@ -1356,10 +1363,7 @@ function createLauncher(productConfig) {
1356
1363
  // pre-date this change won't have the wasm and will still install —
1357
1364
  // they'll just hit the same crash they had before, which is fine.
1358
1365
  if (includeTreeSitterWasm) {
1359
- const tempWasmPath = path.join(
1360
- CONFIG.tempDownloadDir,
1361
- 'tree-sitter.wasm',
1362
- )
1366
+ const tempWasmPath = path.join(stagingDir, 'tree-sitter.wasm')
1363
1367
  if (fs.existsSync(tempWasmPath)) {
1364
1368
  const targetWasmPath = path.join(
1365
1369
  path.dirname(CONFIG.binaryPath),
@@ -1375,13 +1379,14 @@ function createLauncher(productConfig) {
1375
1379
  replacements,
1376
1380
  )
1377
1381
  commitReplacements(replacements)
1382
+ installed = true
1378
1383
  } catch (error) {
1379
1384
  rollbackReplacements(replacements)
1380
1385
  throw error
1381
1386
  } finally {
1382
1387
  removeFileIfPresent(metadataTempPath)
1383
- if (fs.existsSync(CONFIG.tempDownloadDir)) {
1384
- fs.rmSync(CONFIG.tempDownloadDir, { recursive: true })
1388
+ if ((installed || !keepStagingOnFailure) && fs.existsSync(stagingDir)) {
1389
+ fs.rmSync(stagingDir, { recursive: true })
1385
1390
  }
1386
1391
  }
1387
1392
 
@@ -1545,8 +1550,15 @@ function createLauncher(productConfig) {
1545
1550
  if (
1546
1551
  runningProcess.exitCode === null &&
1547
1552
  runningProcess.signalCode === null
1548
- )
1553
+ ) {
1549
1554
  pendingUpdates.set(runningProcess, stagedBinary)
1555
+ // Closing the terminal window kills this launcher too (SIGHUP, or
1556
+ // CTRL_CLOSE_EVENT on Windows) before the exit handler can
1557
+ // install. Mark the staged update ready so the next launch adopts
1558
+ // it instead of a user who never quits from inside the CLI
1559
+ // staying on this build forever.
1560
+ await writeStagedManifest(stagedBinary)
1561
+ }
1550
1562
  return
1551
1563
  }
1552
1564
 
@@ -1605,7 +1617,7 @@ function createLauncher(productConfig) {
1605
1617
  fs.rmSync(CONFIG.tempDownloadDir, { recursive: true, force: true })
1606
1618
  return
1607
1619
  }
1608
- installStagedBinary(staged, { quiet: true })
1620
+ installStagedBinary(staged, { quiet: true, keepStagingOnFailure: true })
1609
1621
  console.log(
1610
1622
  `Updated ${displayName} to ${staged.version}. Ready for your next launch.`,
1611
1623
  )
@@ -1616,6 +1628,202 @@ function createLauncher(productConfig) {
1616
1628
  }
1617
1629
  }
1618
1630
 
1631
+ /**
1632
+ * Deferred staging directories are `<tempDownloadDirName>-<pid>`, plus
1633
+ * `<…>-<pid>.adopting` while a later launcher installs one it adopted. The
1634
+ * pid is the launcher that owns the directory.
1635
+ */
1636
+ const STAGED_MANIFEST_NAME = 'staged.json'
1637
+
1638
+ function parseStagingDirOwner(name) {
1639
+ const prefix = `${tempDownloadDirName}-`
1640
+ if (!name.startsWith(prefix)) return null
1641
+ const match = /^(\d{1,10})(?:\.adopting)?$/.exec(name.slice(prefix.length))
1642
+ return match ? Number(match[1]) : null
1643
+ }
1644
+
1645
+ function isProcessAlive(pid) {
1646
+ try {
1647
+ process.kill(pid, 0)
1648
+ return true
1649
+ } catch (error) {
1650
+ // EPERM: it exists, it just is not ours to signal.
1651
+ return error?.code === 'EPERM'
1652
+ }
1653
+ }
1654
+
1655
+ /**
1656
+ * Record that a staged update is complete: the version, target and sha256 of
1657
+ * every file installStagedBinary() will move. Written last and renamed into
1658
+ * place, so a launcher killed mid-download or mid-extraction leaves a
1659
+ * directory without one, which is only ever deleted. Best effort: a failure
1660
+ * here leaves the exit-time install exactly as it was.
1661
+ *
1662
+ * The hashes are this launcher's own record, checked again before adoption
1663
+ * so a file truncated or altered on disk since is never installed. They are
1664
+ * not a second trust root: the archive was verified against npm's sha256 at
1665
+ * download, and anything able to write this directory can already write the
1666
+ * installed binary next to it.
1667
+ */
1668
+ async function writeStagedManifest({ tempBinaryPath, version, targetKey }) {
1669
+ const stagingDir = path.dirname(tempBinaryPath)
1670
+ const manifestPath = path.join(stagingDir, STAGED_MANIFEST_NAME)
1671
+ const tempManifestPath = `${manifestPath}.tmp`
1672
+ try {
1673
+ const files = {
1674
+ [CONFIG.binaryName]: await computeFileSha256(tempBinaryPath),
1675
+ }
1676
+ const wasmPath = path.join(stagingDir, 'tree-sitter.wasm')
1677
+ if (includeTreeSitterWasm && fs.existsSync(wasmPath)) {
1678
+ files['tree-sitter.wasm'] = await computeFileSha256(wasmPath)
1679
+ }
1680
+ fs.writeFileSync(
1681
+ tempManifestPath,
1682
+ JSON.stringify({ version, target: targetKey, files }),
1683
+ )
1684
+ fs.renameSync(tempManifestPath, manifestPath)
1685
+ } catch {
1686
+ try {
1687
+ removeFileIfPresent(tempManifestPath)
1688
+ } catch {
1689
+ // The directory may already be gone: installed at exit, or removed.
1690
+ }
1691
+ }
1692
+ }
1693
+
1694
+ function readStagedManifest(stagingDir) {
1695
+ try {
1696
+ const manifest = JSON.parse(
1697
+ fs.readFileSync(path.join(stagingDir, STAGED_MANIFEST_NAME), 'utf8'),
1698
+ )
1699
+ const files = manifest?.files
1700
+ if (
1701
+ !isStrictReleaseVersion(manifest?.version) ||
1702
+ !PLATFORM_TARGETS[manifest?.target] ||
1703
+ !files ||
1704
+ typeof files !== 'object' ||
1705
+ !isSha256Hex(files[CONFIG.binaryName]) ||
1706
+ Object.entries(files).some(
1707
+ ([name, digest]) =>
1708
+ !isAllowedArchiveEntry(name, null) || !isSha256Hex(digest),
1709
+ )
1710
+ ) {
1711
+ return null
1712
+ }
1713
+ return { version: manifest.version, targetKey: manifest.target, files }
1714
+ } catch {
1715
+ return null
1716
+ }
1717
+ }
1718
+
1719
+ async function stagedFilesMatch(stagingDir, files) {
1720
+ for (const [name, digest] of Object.entries(files)) {
1721
+ const filePath = path.join(stagingDir, name)
1722
+ if (!fs.existsSync(filePath)) return false
1723
+ const verification = await verifyFileSha256(filePath, digest)
1724
+ if (!verification.ok) return false
1725
+ }
1726
+ // Nothing unlisted may ride along into the install (the wasm is moved
1727
+ // whenever it is present).
1728
+ return fs
1729
+ .readdirSync(stagingDir)
1730
+ .every((name) => name === STAGED_MANIFEST_NAME || name in files)
1731
+ }
1732
+
1733
+ /**
1734
+ * Install an update a previous launcher downloaded but never installed, and
1735
+ * delete what dead launchers left behind.
1736
+ *
1737
+ * A deferred update is installed by the launcher's exit handler, which runs
1738
+ * only when the CLI exits on its own. Closing the terminal window or tab
1739
+ * (SIGHUP; CTRL_CLOSE_EVENT on Windows) kills the launcher first, so a user
1740
+ * who always leaves that way never updated: each launch downloaded the same
1741
+ * release again and left another full copy of the binary in a
1742
+ * `<tempDownloadDirName>-<pid>` directory.
1743
+ *
1744
+ * Runs before the binary starts, so nothing of ours holds the files. A
1745
+ * directory whose owner is still running belongs to a live session that
1746
+ * will install it on exit and is left alone. Claiming by rename means two
1747
+ * launchers starting together never install the same directory twice.
1748
+ * Never throws: a failure here must not stop the CLI from starting.
1749
+ */
1750
+ function removeDirQuietly(dir) {
1751
+ try {
1752
+ fs.rmSync(dir, { recursive: true, force: true })
1753
+ } catch {
1754
+ // Locked (Windows) or already gone; the next launch tries again.
1755
+ }
1756
+ }
1757
+
1758
+ async function adoptOrphanedStagedUpdates() {
1759
+ if (!deferUpdatesUntilExit) return null
1760
+ try {
1761
+ const orphans = []
1762
+ for (const name of fs.readdirSync(CONFIG.configDir)) {
1763
+ const owner = parseStagingDirOwner(name)
1764
+ if (owner === null) continue
1765
+ // Our own pid was just assigned to us, so a directory under it is a
1766
+ // leftover from an earlier process that had the same pid.
1767
+ if (owner !== process.pid && isProcessAlive(owner)) continue
1768
+ const dir = path.join(CONFIG.configDir, name)
1769
+ orphans.push({ dir, staged: readStagedManifest(dir) })
1770
+ }
1771
+ if (orphans.length === 0) return null
1772
+
1773
+ orphans.sort((a, b) =>
1774
+ a.staged && b.staged
1775
+ ? compareVersions(b.staged.version, a.staged.version)
1776
+ : a.staged
1777
+ ? -1
1778
+ : b.staged
1779
+ ? 1
1780
+ : 0,
1781
+ )
1782
+
1783
+ const installedVersion = getCurrentVersion()
1784
+ let adoptedVersion = null
1785
+ for (const { dir, staged } of orphans) {
1786
+ if (
1787
+ adoptedVersion === null &&
1788
+ staged &&
1789
+ (installedVersion === null ||
1790
+ compareVersions(installedVersion, staged.version) < 0) &&
1791
+ isTargetAllowedForThisMachine(staged.targetKey)
1792
+ ) {
1793
+ const claimedDir = `${CONFIG.tempDownloadDir}.adopting`
1794
+ try {
1795
+ fs.renameSync(dir, claimedDir)
1796
+ } catch {
1797
+ continue // Another launcher claimed it first.
1798
+ }
1799
+ try {
1800
+ if (await stagedFilesMatch(claimedDir, staged.files)) {
1801
+ installStagedBinary(
1802
+ {
1803
+ tempBinaryPath: path.join(claimedDir, CONFIG.binaryName),
1804
+ version: staged.version,
1805
+ targetKey: staged.targetKey,
1806
+ },
1807
+ { quiet: true, keepStagingOnFailure: true },
1808
+ )
1809
+ adoptedVersion = staged.version
1810
+ } else {
1811
+ removeDirQuietly(claimedDir)
1812
+ }
1813
+ } catch {
1814
+ // Rolled back by installStagedBinary, which kept the directory.
1815
+ // It is ours until we exit, then any later launch retries it.
1816
+ }
1817
+ continue
1818
+ }
1819
+ removeDirQuietly(dir)
1820
+ }
1821
+ return adoptedVersion
1822
+ } catch {
1823
+ return null
1824
+ }
1825
+ }
1826
+
1619
1827
  /**
1620
1828
  * Make captured output safe to print back to the terminal.
1621
1829
  *
@@ -1845,6 +2053,12 @@ function createLauncher(productConfig) {
1845
2053
  ...process.env,
1846
2054
  ...optionEnv,
1847
2055
  CODEBUFF_LAUNCHER_PID: String(process.pid),
2056
+ // Which npm wrapper started this binary. The wrapper never updates
2057
+ // itself, so this is how launch telemetry tells an install that
2058
+ // cannot install deferred updates from one that can.
2059
+ ...(wrapperVersion
2060
+ ? { CODEBUFF_LAUNCHER_VERSION: String(wrapperVersion) }
2061
+ : {}),
1848
2062
  },
1849
2063
  })
1850
2064
  } catch (err) {
@@ -2069,6 +2283,7 @@ function createLauncher(productConfig) {
2069
2283
  }
2070
2284
  if (startupBanner.length > 0) console.log('')
2071
2285
 
2286
+ await adoptOrphanedStagedUpdates()
2072
2287
  await ensureBinaryReady()
2073
2288
 
2074
2289
  const child = spawnInstalledBinary()
@@ -2099,6 +2314,7 @@ function createLauncher(productConfig) {
2099
2314
  isStartupCpuFeatureCrash,
2100
2315
  stderrReportsMissingCpuFeature,
2101
2316
  tryFallbackToBaseline,
2317
+ adoptOrphanedStagedUpdates,
2102
2318
  printCrashDiagnostics,
2103
2319
  checkForUpdates,
2104
2320
  spawnInstalledBinary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freebuff",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "The world's strongest free coding agent",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -44,13 +44,13 @@
44
44
  "provenance": false
45
45
  },
46
46
  "binaryChecksums": {
47
- "linux-x64": "a4e6e88bf289fcf86c862cb621d7e8aa0c0c7fdd2f8d230b16ab8f018d151755",
48
- "linux-x64-baseline": "29011714d3989e67993bdb058730028fb3cea607d09c291e07c06df05228b69c",
49
- "linux-arm64": "917b1997cc6deee8fbc7db5f2db213e4dab5aec8737d9abf0e149c9473f39b90",
50
- "darwin-x64": "40fd75bdd6e56cd085bb2187f624819a43912ec18d7e57f8236b1fe7367a37d5",
51
- "darwin-x64-baseline": "e47b9f6c7b88bd3a58b516c131cd7e0261fd1f7ed23f1984027bb1302136c6de",
52
- "darwin-arm64": "7919611702a2ad304d78bace06dc02bdb3d5f6e0fd8f87410ce0472626df3fe2",
53
- "win32-x64": "387a07df0ccde41e5820c91b4007c56aa6f090625483013226a222918fbd8cad",
54
- "win32-x64-baseline": "c087e91f8d58b1c932f8722efd4f13a5f9994f9389b8ce5bb0d5d1c5ae5cadf7"
47
+ "linux-x64": "b7b9a1895573fc217054ef9e038b6b4007bd090454a3a7e8aa440ee87fa3431f",
48
+ "linux-x64-baseline": "f285aad92a017f9e8a44c41514ee61dfd0170418c7eda6f36cf95e3b19a30ca0",
49
+ "linux-arm64": "83c0f126f1f6d69b14855a3bafa84f97dd0ef209a6bcbcb175592c53fc87c5f3",
50
+ "darwin-x64": "d74ebb1fdb7f01be838bcc90c7691c7b8a53b7a4efc17c51a42e50f491a66cc2",
51
+ "darwin-x64-baseline": "67e7b4afa784497eb30e122e202ba68866554f2f87fb3ef4c8b73177405ff5b8",
52
+ "darwin-arm64": "8ce3e35caf2edf826ac99c9b416fdb636db0ce8a83f1e0b3588f89634df96c93",
53
+ "win32-x64": "64abf928dc43a6d5cd783a8beedc5ff6820bf4275f1b9056079d1afe5d644773",
54
+ "win32-x64-baseline": "ddce1008c6c151a734a45e628df4f75ee673f6c2984118974b8f6f1c7d087e83"
55
55
  }
56
56
  }