diff --git a/scripts/make-latest-json.js b/scripts/make-latest-json.js new file mode 100644 index 0000000..2ead646 --- /dev/null +++ b/scripts/make-latest-json.js @@ -0,0 +1,192 @@ +// Write the Tauri updater manifest (`latest.json`) for a release. +// +// Usage: node scripts/make-latest-json.js [--windows-only] +// +// `` holds exactly the release assets, already renamed to space-free +// names. The script checks that set before it writes `/latest.json`, +// because the same files are uploaded next to the manifest: +// +// - Legacy clients (v0.5.0 and earlier) read `/releases/latest` through the +// GitHub API and download the asset named `claude-code-usage-bubble.exe`, +// else the first asset ending in `.exe`. So exactly one `.exe` asset may +// exist, under that exact name. +// - GitHub rewrites spaces in asset names, so a name with whitespace would no +// longer match the URL written into the manifest. +// - Every platform entry the app looks up must be present; a missing one +// would leave those installs without updates. + +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const REPO = 'tiennm99/claude-code-usage-bubble'; +export const WINDOWS_EXE = 'claude-code-usage-bubble.exe'; +export const MANIFEST_NAME = 'latest.json'; + +const TAG_PATTERN = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; + +/** + * @typedef {object} PlatformEntry + * @property {string} url Release-download URL of the update asset. + * @property {string} signature Content of the asset's `.sig` file. + */ + +/** + * @typedef {object} Manifest + * @property {string} version Version without the leading `v`. + * @property {string} notes Short release note. + * @property {string} pub_date RFC 3339 publication time. + * @property {Record} platforms Entries by updater target. + */ + +/** + * The version a release tag announces. + * @param {string} tag Tag such as `v1.2.3` or `v0.0.0-test`. + * @returns {string} The version without the leading `v`. + * @throws {Error} When the tag is not `v..[-suffix]`. + */ +export function versionFromTag(tag) { + const match = TAG_PATTERN.exec(tag); + if (!match) { + throw new Error(`tag "${tag}" is not of the form vX.Y.Z or vX.Y.Z-suffix`); + } + return match[1]; +} + +/** + * The updater platforms a release carries, with the asset each one installs. + * @param {string} version Release version. + * @param {boolean} windowsOnly True for a Windows-only (legacy rollback) release. + * @returns {Record} Asset name by updater target. + */ +export function platformAssets(version, windowsOnly) { + /** @type {Record} */ + const platforms = { 'windows-x86_64': WINDOWS_EXE }; + if (!windowsOnly) { + platforms['linux-x86_64'] = `claude-code-usage-bubble_${version}_amd64.AppImage`; + } + return platforms; +} + +/** + * Every asset a release must upload besides `latest.json`. + * @param {string} version Release version. + * @param {boolean} windowsOnly True for a Windows-only release. + * @returns {string[]} Asset names, sorted. + */ +export function expectedAssets(version, windowsOnly) { + const names = Object.values(platformAssets(version, windowsOnly)).flatMap((n) => [n, `${n}.sig`]); + if (!windowsOnly) { + names.push(`claude-code-usage-bubble_${version}_amd64.deb`); + } + return names.sort(); +} + +/** + * Check a release's asset names against what every client needs. + * @param {string[]} names Asset names, `latest.json` excluded. + * @param {string} version Release version. + * @param {boolean} windowsOnly True for a Windows-only release. + * @throws {Error} On a name with whitespace, a second `.exe`, a missing asset or an unexpected one. + */ +export function checkAssetNames(names, version, windowsOnly) { + const spaced = names.filter((n) => /\s/.test(n)); + if (spaced.length > 0) { + throw new Error(`asset names must not contain whitespace: ${spaced.join(', ')}`); + } + const strayExe = names.filter((n) => n.toLowerCase().endsWith('.exe') && n !== WINDOWS_EXE); + if (strayExe.length > 0) { + throw new Error( + `the only .exe asset allowed is ${WINDOWS_EXE}; legacy clients could pick: ${strayExe.join(', ')}`, + ); + } + const expected = expectedAssets(version, windowsOnly); + const missing = expected.filter((n) => !names.includes(n)); + if (missing.length > 0) { + throw new Error(`missing release assets: ${missing.join(', ')}`); + } + const unexpected = names.filter((n) => !expected.includes(n)); + if (unexpected.length > 0) { + throw new Error(`unexpected release assets: ${unexpected.join(', ')}`); + } +} + +/** + * The download URL GitHub serves a release asset from. + * @param {string} tag Release tag. + * @param {string} name Asset name. + * @returns {string} The URL. + */ +export function assetUrl(tag, name) { + return `https://github.com/${REPO}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; +} + +/** + * Build the manifest for a release whose asset names were already checked. + * @param {object} input Release facts. + * @param {string} input.tag Release tag. + * @param {boolean} input.windowsOnly True for a Windows-only release. + * @param {(name: string) => string} input.readSignature Content of the named `.sig` asset. + * @param {Date} input.now Publication time. + * @returns {Manifest} The manifest. + * @throws {Error} When a signature is empty. + */ +export function buildManifest({ tag, windowsOnly, readSignature, now }) { + const version = versionFromTag(tag); + /** @type {Record} */ + const platforms = {}; + for (const [target, name] of Object.entries(platformAssets(version, windowsOnly))) { + const signature = readSignature(`${name}.sig`).trim(); + if (signature === '') { + throw new Error(`${name}.sig is empty`); + } + platforms[target] = { url: assetUrl(tag, name), signature }; + } + return { + version, + notes: `See https://github.com/${REPO}/releases/tag/${tag}`, + pub_date: now.toISOString(), + platforms, + }; +} + +/** + * Check the assets in `dir` and write `dir/latest.json`. + * @param {string[]} argv Arguments after the script path. + * @param {Date} [now] Publication time. + * @returns {Manifest} The manifest written. + * @throws {Error} On bad arguments or any failed check. + */ +export function run(argv, now = new Date()) { + const windowsOnly = argv.includes('--windows-only'); + const positional = argv.filter((a) => a !== '--windows-only'); + if (positional.length !== 2 || positional.some((a) => a.startsWith('--'))) { + throw new Error('usage: make-latest-json.js [--windows-only] '); + } + const [tag, dir] = positional; + const version = versionFromTag(tag); + const names = readdirSync(dir).filter((n) => n !== MANIFEST_NAME); + const notFiles = names.filter((n) => !statSync(join(dir, n)).isFile()); + if (notFiles.length > 0) { + throw new Error(`not a file: ${notFiles.join(', ')}`); + } + checkAssetNames(names, version, windowsOnly); + const manifest = buildManifest({ + tag, + windowsOnly, + readSignature: (name) => readFileSync(join(dir, name), 'utf8'), + now, + }); + writeFileSync(join(dir, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const manifest = run(process.argv.slice(2)); + console.log(`latest.json ${manifest.version}: ${Object.keys(manifest.platforms).join(', ')}`); + } catch (err) { + console.error(`make-latest-json: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } +} diff --git a/tests/js/make-latest-json.test.js b/tests/js/make-latest-json.test.js new file mode 100644 index 0000000..6b880b0 --- /dev/null +++ b/tests/js/make-latest-json.test.js @@ -0,0 +1,153 @@ +// Release manifest script: the asset set every client relies on and the +// `latest.json` shape the Tauri updater reads. + +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +import { + assetUrl, + checkAssetNames, + expectedAssets, + run, + versionFromTag, +} from '../../scripts/make-latest-json.js'; + +const TAG = 'v0.6.0'; +const VERSION = '0.6.0'; +const APPIMAGE = `claude-code-usage-bubble_${VERSION}_amd64.AppImage`; +const DEB = `claude-code-usage-bubble_${VERSION}_amd64.deb`; +const NOW = new Date('2026-10-21T09:30:00.000Z'); + +/** @type {string} */ +let dir; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'make-latest-json-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** + * Create release assets in the temp dir; a `.sig` holds a fake signature. + * @param {string[]} names Asset names. + */ +const put = (names) => { + for (const name of names) { + writeFileSync(join(dir, name), name.endsWith('.sig') ? `sig-of-${name}\n` : 'bytes'); + } +}; + +const FULL = ['claude-code-usage-bubble.exe', 'claude-code-usage-bubble.exe.sig', APPIMAGE, `${APPIMAGE}.sig`, DEB]; + +describe('versionFromTag', () => { + it('strips the v and keeps a pre-release suffix', () => { + assert.equal(versionFromTag('v1.2.3'), '1.2.3'); + assert.equal(versionFromTag('v0.0.0-test'), '0.0.0-test'); + }); + + it('rejects tags that are not vX.Y.Z', () => { + for (const tag of ['1.2.3', 'v1.2', 'v1.2.3.4', 'v1.2.3-', 'release', '']) { + assert.throws(() => versionFromTag(tag), /not of the form/, tag); + } + }); +}); + +describe('run', () => { + it('writes exactly the two platforms with the signature content', () => { + put(FULL); + const manifest = run([TAG, dir], NOW); + const written = JSON.parse(readFileSync(join(dir, 'latest.json'), 'utf8')); + assert.deepEqual(written, manifest); + assert.deepEqual(Object.keys(written).sort(), ['notes', 'platforms', 'pub_date', 'version']); + assert.equal(written.version, VERSION); + assert.equal(written.pub_date, '2026-10-21T09:30:00.000Z'); + assert.deepEqual(Object.keys(written.platforms).sort(), ['linux-x86_64', 'windows-x86_64']); + assert.equal(written.platforms['windows-x86_64'].signature, 'sig-of-claude-code-usage-bubble.exe.sig'); + assert.equal(written.platforms['linux-x86_64'].signature, `sig-of-${APPIMAGE}.sig`); + }); + + it('points each URL at the renamed asset of the same name', () => { + put(FULL); + const { platforms } = run([TAG, dir], NOW); + const base = 'https://github.com/tiennm99/claude-code-usage-bubble/releases/download/v0.6.0/'; + assert.equal(platforms['windows-x86_64'].url, `${base}claude-code-usage-bubble.exe`); + assert.equal(platforms['linux-x86_64'].url, `${base}${APPIMAGE}`); + for (const { url } of Object.values(platforms)) { + assert.ok(FULL.includes(url.slice(base.length)), url); + } + }); + + it('writes only the Windows entry with --windows-only', () => { + put(['claude-code-usage-bubble.exe', 'claude-code-usage-bubble.exe.sig']); + const manifest = run(['--windows-only', TAG, dir], NOW); + assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); + }); + + it('ignores a latest.json left by an earlier run', () => { + put(FULL); + run([TAG, dir], NOW); + assert.doesNotThrow(() => run([TAG, dir], NOW)); + }); + + it('fails on an asset name with a space', () => { + put([...FULL.filter((n) => n !== APPIMAGE), 'Claude Code Usage Bubble_0.6.0_amd64.AppImage']); + assert.throws(() => run([TAG, dir], NOW), /whitespace/); + }); + + it('fails on a second .exe asset, whatever its case', () => { + put([...FULL, 'claude-code-usage-bubble-setup.exe']); + assert.throws(() => run([TAG, dir], NOW), /only \.exe asset allowed/); + rmSync(join(dir, 'claude-code-usage-bubble-setup.exe')); + put(['Other.EXE']); + assert.throws(() => run([TAG, dir], NOW), /only \.exe asset allowed/); + }); + + it('fails when a platform asset or its signature is missing', () => { + put(FULL.filter((n) => n !== `${APPIMAGE}.sig`)); + assert.throws(() => run([TAG, dir], NOW), /missing release assets: .*AppImage\.sig/); + }); + + it('fails when the AppImage carries another version', () => { + put([...FULL.filter((n) => !n.startsWith(APPIMAGE)), 'claude-code-usage-bubble_0.5.0_amd64.AppImage']); + assert.throws(() => run([TAG, dir], NOW), /missing release assets/); + }); + + it('fails on an empty signature', () => { + put(FULL); + writeFileSync(join(dir, 'claude-code-usage-bubble.exe.sig'), '\n'); + assert.throws(() => run([TAG, dir], NOW), /exe\.sig is empty/); + }); + + it('fails on bad arguments', () => { + assert.throws(() => run([TAG], NOW), /usage/); + assert.throws(() => run(['--linux-only', TAG, dir], NOW), /usage/); + assert.throws(() => run(['0.6.0', dir], NOW), /not of the form/); + }); +}); + +describe('checkAssetNames', () => { + it('rejects Linux assets on a Windows-only release', () => { + assert.throws( + () => checkAssetNames([...expectedAssets(VERSION, true), DEB], VERSION, true), + /unexpected release assets/, + ); + }); + + it('lists the full asset set', () => { + assert.deepEqual(expectedAssets(VERSION, false), [...FULL].sort()); + }); +}); + +describe('assetUrl', () => { + it('builds the release-download URL', () => { + assert.equal( + assetUrl('v0.0.0-test', 'claude-code-usage-bubble.exe'), + 'https://github.com/tiennm99/claude-code-usage-bubble/releases/download/v0.0.0-test/claude-code-usage-bubble.exe', + ); + }); +});