mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-22 04:14:52 +00:00
build: generate the updater latest.json and guard the release asset set
This commit is contained in:
1 parent
94722f2741
commit
51d20b16b1
2 files changed
+345
No files matched your search
@@ -0,0 +1,192 @@
|
||||
// Write the Tauri updater manifest (`latest.json`) for a release.
|
||||
//
|
||||
// Usage: node scripts/make-latest-json.js [--windows-only] <tag> <dir>
|
||||
//
|
||||
// `<dir>` holds exactly the release assets, already renamed to space-free
|
||||
// names. The script checks that set before it writes `<dir>/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<string, PlatformEntry>} 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<major>.<minor>.<patch>[-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<string, string>} Asset name by updater target.
|
||||
*/
|
||||
export function platformAssets(version, windowsOnly) {
|
||||
/** @type {Record<string, string>} */
|
||||
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<string, PlatformEntry>} */
|
||||
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] <tag> <dir>');
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user