mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
212 lines
8.6 KiB
JavaScript
212 lines
8.6 KiB
JavaScript
/**
|
|
* Resolve every artifact the site offers against the releases that actually exist.
|
|
*
|
|
* Two pages read what this writes — `/download` through `data/downloads.ts` and
|
|
* `/install` through `data/app-catalog.ts` — so a build either page names is
|
|
* resolved once, here, and the two cannot drift into disagreeing about a URL.
|
|
*
|
|
* The page names a PLATFORM and an asset PATTERN; this writes the URL. That
|
|
* split exists because half our repos version the filename — the extension
|
|
* publishes `hanzo-ai-desktop-macos-v1.9.31.dmg`, so the obvious
|
|
* `releases/latest/download/<name>` link works until 1.9.32 ships and then
|
|
* 404s for everyone. A pattern cannot go stale that way.
|
|
*
|
|
* It also catches the other direction, which is how this started: hanzoai/desktop
|
|
* 1.1.48 published a macOS `.sig` and no macOS binary, so the tile a reader
|
|
* would have clicked answered 404 while the release looked healthy. An asset
|
|
* that is not in the payload is not written, and a platform with no asset does
|
|
* not render — the page cannot advertise a build that does not exist.
|
|
*
|
|
* Three rules, the same three every sync in this estate keeps:
|
|
* - never fail the build on an unreachable API — the committed snapshot is
|
|
* the designed fallback, and a page must not need github.com to deploy;
|
|
* - never regress — a payload resolving FEWER assets than the snapshot is far
|
|
* more likely to be a degraded response than a product being retired, so it
|
|
* is refused and what it would have dropped is printed;
|
|
* - never go quiet — every run prints what resolved and what did not.
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { dirname, join } from 'node:path'
|
|
|
|
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'data', 'releases.json')
|
|
|
|
/**
|
|
* What each surface publishes, as `platform -> pattern`.
|
|
*
|
|
* The pattern matches the asset NAME. Checksums and signatures are never
|
|
* candidates: a `.sig` is not a download, and treating one as a build is the
|
|
* exact defect above.
|
|
*/
|
|
const SURFACES = {
|
|
cli: {
|
|
repo: 'hanzoai/cli',
|
|
assets: {
|
|
'macOS (Apple Silicon)': /^hanzo-darwin-arm64\.tar\.gz$/,
|
|
'macOS (Intel)': /^hanzo-darwin-amd64\.tar\.gz$/,
|
|
Windows: /^hanzo-windows-amd64\.tar\.gz$/,
|
|
'Linux (x86_64)': /^hanzo-linux-amd64\.tar\.gz$/,
|
|
'Linux (arm64)': /^hanzo-linux-arm64\.tar\.gz$/,
|
|
},
|
|
},
|
|
desktop: {
|
|
repo: 'hanzoai/desktop',
|
|
assets: {
|
|
macOS: /^Hanzo[.-]Desktop.*\.dmg$/,
|
|
'Linux (AppImage)': /^Hanzo[.-]Desktop.*amd64.*\.AppImage$/,
|
|
'Linux (deb)': /^Hanzo[.-]Desktop.*amd64.*\.deb$/,
|
|
'Linux (arm64)': /^Hanzo[.-]Desktop.*aarch64.*\.AppImage$/,
|
|
},
|
|
},
|
|
browser: {
|
|
repo: 'hanzoai/extension',
|
|
assets: {
|
|
Chrome: /^hanzo-ai-chrome-v[\d.]+\.zip$/,
|
|
Firefox: /^hanzo-ai-firefox-v[\d.]+\.zip$/,
|
|
Safari: /^hanzo-ai-safari-v[\d.]+\.zip$/,
|
|
Edge: /^hanzo-ai-edge-v[\d.]+\.zip$/,
|
|
},
|
|
},
|
|
editor: {
|
|
repo: 'hanzoai/extension',
|
|
assets: {
|
|
'VS Code': /^hanzo-ai-vscode-v[\d.]+\.vsix$/,
|
|
Cursor: /^hanzo-ai-cursor-v[\d.]+\.vsix$/,
|
|
Windsurf: /^hanzo-ai-windsurf-v[\d.]+\.vsix$/,
|
|
Antigravity: /^hanzo-ai-antigravity-v[\d.]+\.vsix$/,
|
|
JetBrains: /^hanzo-ai-jetbrains-v[\d.]+\.zip$/,
|
|
},
|
|
},
|
|
// Apps that Hanzo installs INTO, the same way browser/editor/desktop are.
|
|
// `office-localhost` is a sideload build for development and is not one of
|
|
// these — the version anchor in each pattern is what keeps it out.
|
|
host: {
|
|
repo: 'hanzoai/extension',
|
|
assets: {
|
|
Office: /^hanzo-ai-office-v[\d.]+\.zip$/,
|
|
Outlook: /^hanzo-ai-outlook-v[\d.]+\.zip$/,
|
|
Figma: /^hanzo-ai-figma-v[\d.]+\.zip$/,
|
|
Sketch: /^hanzo-ai-sketch-v[\d.]+\.zip$/,
|
|
Teams: /^hanzo-ai-teams-v[\d.]+\.zip$/,
|
|
Zendesk: /^hanzo-ai-zendesk-v[\d.]+\.zip$/,
|
|
'Claude Desktop': /^hanzo-ai-claude-v[\d.]+\.dxt$/,
|
|
JupyterLab: /^hanzo_jupyter-[\d.]+-py3-none-any\.whl$/,
|
|
},
|
|
},
|
|
}
|
|
|
|
const snapshot = () => {
|
|
try {
|
|
return JSON.parse(readFileSync(OUT, 'utf8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The latest release of `repo`, asked for the way a READER asks for it.
|
|
*
|
|
* No credential, ever, and that is the whole point of this function. It used to
|
|
* send `GITHUB_TOKEN` when CI had one, which asks a question no visitor can
|
|
* ask: hanzoai/extension is a PRIVATE repo, so an authenticated build resolved
|
|
* all 23 of its assets, wrote them to the snapshot, and every reader who
|
|
* clicked one got 404. Measured 2026-08-27 against the live page: the six
|
|
* desktop, browser and editor links on hanzo.app/download answered 404
|
|
* anonymously and 200 with a token, and hanzoai/cli — the one PUBLIC repo —
|
|
* was the only surface on the page that worked.
|
|
*
|
|
* A build that can see more than a reader can fetch is the defect, not the
|
|
* feature. Asking anonymously makes the resolver and the reader the same
|
|
* client, so the page cannot offer a download the reader cannot take.
|
|
*
|
|
* `null` is a real answer, not a failure: 404 means this repo publishes nothing
|
|
* to the public — private, renamed, or gone — and in every one of those cases
|
|
* the reader gets 404 too, so the right page is one with no card. Anything else
|
|
* (rate limit, 5xx, DNS) is an outage and throws, which keeps the snapshot.
|
|
*/
|
|
async function release(repo) {
|
|
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
|
headers: { accept: 'application/vnd.github+json' },
|
|
})
|
|
if (res.status === 404) return null
|
|
if (!res.ok) throw new Error(`${repo}: ${res.status}`)
|
|
return res.json()
|
|
}
|
|
|
|
async function main() {
|
|
const held = snapshot()
|
|
let next
|
|
let served = new Set()
|
|
try {
|
|
const repos = [...new Set(Object.values(SURFACES).map((s) => s.repo))]
|
|
const payloads = Object.fromEntries(
|
|
await Promise.all(repos.map(async (r) => [r, await release(r)])),
|
|
)
|
|
|
|
// The repos that served a release to an anonymous caller. A repo missing
|
|
// from this set published nothing a reader can take, and its surfaces
|
|
// resolve to no platforms rather than to links that answer 404.
|
|
served = new Set(repos.filter((r) => payloads[r]))
|
|
|
|
next = {}
|
|
for (const [id, { repo, assets }] of Object.entries(SURFACES)) {
|
|
const payload = payloads[repo]
|
|
if (!payload) {
|
|
next[id] = { repo, platforms: {} }
|
|
console.log(` ${id.padEnd(8)} ${'—'.padEnd(10)} 0/${Object.keys(assets).length} NOT PUBLIC: ${repo} serves no release to a reader`)
|
|
continue
|
|
}
|
|
const published = payload.assets ?? []
|
|
const found = {}
|
|
for (const [platform, pattern] of Object.entries(assets)) {
|
|
const hit = published.find((a) => pattern.test(a.name))
|
|
if (hit) found[platform] = { url: hit.browser_download_url, size: hit.size }
|
|
}
|
|
next[id] = { repo, version: payload.tag_name, platforms: found }
|
|
const missing = Object.keys(assets).filter((p) => !found[p])
|
|
console.log(
|
|
` ${id.padEnd(8)} ${payload.tag_name.padEnd(10)} ${Object.keys(found).length}/${Object.keys(assets).length}` +
|
|
(missing.length ? ` NOT PUBLISHED: ${missing.join(', ')}` : ''),
|
|
)
|
|
}
|
|
} catch (err) {
|
|
// An unreachable API is not a build failure. The snapshot is the fallback.
|
|
console.log(` releases unreachable (${err.message}) — keeping the committed snapshot`)
|
|
if (!held) throw new Error('no snapshot on disk and no API: cannot resolve downloads')
|
|
return
|
|
}
|
|
|
|
// Never regress: a shrinking payload is a degraded response, not a retirement.
|
|
//
|
|
// Counted across the repos that SERVED this run, and only those. A repo that
|
|
// answered 404 has nothing to compare, and its entries in the snapshot are
|
|
// links a reader cannot take — so counting them would let dead URLs outvote
|
|
// the truth and hold themselves on the page permanently. That is not a
|
|
// hypothetical: this guard was what kept six unreachable extension links
|
|
// published while the resolver believed it had verified all of them.
|
|
if (held) {
|
|
const count = (s) =>
|
|
Object.entries(s).reduce(
|
|
(n, [id, v]) => (served.has(SURFACES[id]?.repo) ? n + Object.keys(v.platforms).length : n),
|
|
0,
|
|
)
|
|
if (count(next) < count(held)) {
|
|
console.log(` REFUSED: resolves ${count(next)} assets, snapshot holds ${count(held)}`)
|
|
for (const id of Object.keys(held)) {
|
|
const gone = Object.keys(held[id].platforms).filter((p) => !next[id]?.platforms[p])
|
|
if (gone.length) console.log(` ${id}: would drop ${gone.join(', ')}`)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
writeFileSync(OUT, `${JSON.stringify(next, null, 2)}\n`)
|
|
console.log(` wrote ${OUT}`)
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err.message)
|
|
process.exit(1)
|
|
})
|