Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Context, OverrideConfig } from "./types/index.js";
import process from "node:process";
import { Command } from "commander";
import { name, version } from "../package.json" with { type: "json" };
import { Build, Config, Release, Serve, Test } from "./index.js";
import { Build, Config, ManifestCommand, Release, Serve, Test } from "./index.js";
import { checkGitIgnore } from "./utils/gitignore.js";
import { logger } from "./utils/logger.js";
import { updateNotifier } from "./utils/updater.js";
Expand Down Expand Up @@ -92,6 +92,16 @@ async function main() {
});
});

cli
.command("manifest:update-max-version")
.description("Update strict_max_version in manifest.json")
.argument("[path]", "Path to manifest.json file")
.action(async (path) => {
const ctx = await Config.loadConfig({});
const instance = new ManifestCommand(ctx);
await instance.updateMaxVersion(path);
});

cli.arguments("<command>").action((cmd) => {
cli.outputHelp();
logger.error(`Unknown command name "${cmd}".`);
Expand Down
37 changes: 37 additions & 0 deletions src/core/builder/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@ import type { Manifest } from "../../types/manifest.js";
import { toMerged } from "es-toolkit";
import { outputJSON, pathExists, readJSON } from "fs-extra/esm";
import { logger } from "../../utils/logger.js";
import { compareVersions, getZoteroVersionInfo, parseMajorVersion } from "../../utils/zotero-version.js";

/**
* Check and update strict_max_version in manifest
*/
async function checkStrictMaxVersion(manifest: Manifest): Promise<void> {
try {
const versionInfo = await getZoteroVersionInfo();
const latestBeta = versionInfo.beta.mac; // All platforms have the same version
const latestRelease = versionInfo.release.mac;

const currentMaxVersion = manifest.applications?.zotero?.strict_max_version;

if (!currentMaxVersion) {
// Auto-fill with latest beta major version + 1
const majorVersion = parseMajorVersion(latestBeta);
const autoFilledVersion = `${majorVersion + 1}.0`;
manifest.applications.zotero.strict_max_version = autoFilledVersion;
logger.info(`Auto-filled strict_max_version to ${autoFilledVersion} (latest beta: ${latestBeta})`);
}
else {
// Check if current version is outdated
if (compareVersions(currentMaxVersion, latestRelease) < 0) {
logger.warn(`strict_max_version (${currentMaxVersion}) is older than the latest Zotero release (${latestRelease})`);
logger.warn(`To update, run: npx zotero-plugin manifest:update-max-version`);
}
}
}
catch (error) {
logger.debug(`Failed to check Zotero version: ${error}`);
// Don't fail the build if version check fails
}
}

export default async function buildManifest(ctx: Context): Promise<void> {
if (!ctx.build.makeManifest.enable)
Expand All @@ -28,6 +61,10 @@ export default async function buildManifest(ctx: Context): Promise<void> {
};

const data: Manifest = toMerged(userData, template);

// Check and update strict_max_version
await checkStrictMaxVersion(data);

logger.debug(`manifest: ${JSON.stringify(data, null, 2)}`);

outputJSON(manifestPath, data, { spaces: 2 });
Expand Down
93 changes: 93 additions & 0 deletions src/core/manifest-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Manifest } from "../types/manifest.js";
import { basename } from "node:path";
import process from "node:process";
import { pathExists, readJSON, writeJSON } from "fs-extra/esm";
import { getZoteroVersionInfo, parseMajorVersion } from "../utils/zotero-version.js";
import { Base } from "./base.js";

export default class ManifestCommand extends Base {
/**
* Update strict_max_version in manifest.json
*/
async updateMaxVersion(manifestPath?: string): Promise<void> {
// Determine the manifest file path
let targetPath: string | null = null;
if (manifestPath) {
targetPath = manifestPath;
}
else {
// Search for manifest.json in common locations
const possiblePaths = [
"manifest.json",
"src/manifest.json",
"addon/manifest.json",
];

for (const p of possiblePaths) {
if (await pathExists(p)) {
targetPath = p;
break;
}
}

if (!targetPath) {
this.logger.error("manifest.json not found in current directory or common locations");
this.logger.error("Please specify the path to manifest.json");
process.exit(1);
}
}

if (!await pathExists(targetPath)) {
this.logger.error(`manifest.json not found at ${targetPath}`);
process.exit(1);
}

try {
const manifest = await readJSON(targetPath) as Manifest;

// Fetch latest version info
const versionInfo = await getZoteroVersionInfo();
const latestBeta = versionInfo.beta.mac;

// Calculate new version
const majorVersion = parseMajorVersion(latestBeta);
const newMaxVersion = `${majorVersion + 1}.0`;

// Update manifest
if (!manifest.applications) {
manifest.applications = { zotero: { id: "", update_url: "" } };
}
if (!manifest.applications.zotero) {
manifest.applications.zotero = { id: "", update_url: "" };
}

const oldMaxVersion = manifest.applications.zotero.strict_max_version;
manifest.applications.zotero.strict_max_version = newMaxVersion;

// Write back to file
await writeJSON(targetPath, manifest, { spaces: 2 });

this.logger.success(`Updated strict_max_version in ${basename(targetPath)}`);
if (oldMaxVersion) {
this.logger.info(` ${oldMaxVersion} -> ${newMaxVersion}`);
}
else {
this.logger.info(` Added: ${newMaxVersion}`);
}
this.logger.info(`Latest Zotero beta: ${latestBeta}`);
}
catch (error) {
this.logger.error(`Failed to update manifest: ${error}`);
process.exit(1);
}
}

async run(): Promise<void> {
// This is a no-op, subcommands should be called directly
this.logger.info("Use 'zotero-plugin manifest:update-max-version' to update strict_max_version");
}

exit(): void {
// No cleanup needed
}
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineConfig, loadConfig } from "./config.js";
import Build from "./core/builder/index.js";
import ManifestCommand from "./core/manifest-command.js";
import Release from "./core/releaser/index.js";
import Serve from "./core/server.js";
import Test from "./core/tester/index.js";
Expand All @@ -12,4 +13,4 @@ const Config: {
loadConfig,
};

export { Build, Config, defineConfig, Release, Serve, Test };
export { Build, Config, defineConfig, ManifestCommand, Release, Serve, Test };
48 changes: 48 additions & 0 deletions src/utils/zotero-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { compareVersions, parseMajorVersion } from "./zotero-version.js";

describe("zotero-version utilities", () => {
describe("parseMajorVersion", () => {
it("should parse beta version", () => {
expect(parseMajorVersion("9.0-beta.21+1a89239a1")).toBe(9);
});

it("should parse release version", () => {
expect(parseMajorVersion("9.0")).toBe(9);
});

it("should handle single digit version", () => {
expect(parseMajorVersion("10.0")).toBe(10);
});

it("should throw on invalid format", () => {
expect(() => parseMajorVersion("invalid")).toThrow("Invalid version format");
});
});

describe("compareVersions", () => {
it("should return -1 when v1 < v2", () => {
expect(compareVersions("8.0", "9.0")).toBe(-1);
});

it("should return 0 when v1 === v2", () => {
expect(compareVersions("9.0", "9.0")).toBe(0);
});

it("should return 1 when v1 > v2", () => {
expect(compareVersions("9.0", "8.0")).toBe(1);
});

it("should handle beta versions", () => {
expect(compareVersions("9.0-beta.20", "9.0-beta.21")).toBe(-1);
});

it("should handle mixed version formats", () => {
expect(compareVersions("9.0", "9.0-beta.21")).toBe(-1);
});

it("should handle different lengths", () => {
expect(compareVersions("9", "9.0.1")).toBe(-1);
});
});
});
142 changes: 142 additions & 0 deletions src/utils/zotero-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { ensureDir, pathExists, readJSON, writeJSON } from "fs-extra/esm";

/**
* Platform identifier for version information
*/
export type Platform = "mac" | "win-x64" | "win-arm64" | "win32" | "linux-x86_64" | "linux-i686" | "linux-arm64";

/**
* Version info structure
*/
export interface ZoteroVersionInfo {
lastUpdated: number;
release: Record<Platform, string>;
beta: Record<Platform, string>;
}

const CACHE_DIR = join(homedir(), ".scaffold", "cache");
const CACHE_FILE = join(CACHE_DIR, "zotero-version.json");
const CACHE_TTL = 3 * 24 * 60 * 60 * 1000; // 3 days in milliseconds
const API_BETA = "https://www.zotero.org/download/client/version?channel=beta";
const API_RELEASE = "https://www.zotero.org/download/client/version?channel=release";

/**
* Fetch Zotero version information from API
*/
async function fetchVersionsFromAPI(): Promise<Omit<ZoteroVersionInfo, "lastUpdated">> {
const [betaRes, releaseRes] = await Promise.all([
fetch(API_BETA),
fetch(API_RELEASE),
]);

if (!betaRes.ok || !releaseRes.ok) {
throw new Error("Failed to fetch Zotero version information from API");
}

const [beta, release] = await Promise.all([
betaRes.json(),
releaseRes.json(),
]);

return {
beta: beta as Record<Platform, string>,
release: release as Record<Platform, string>,
};
}

/**
* Load version cache from disk
*/
async function loadCache(): Promise<ZoteroVersionInfo | null> {
try {
if (!await pathExists(CACHE_FILE)) {
return null;
}

const cache = await readJSON(CACHE_FILE) as ZoteroVersionInfo;
const age = Date.now() - cache.lastUpdated;

// Check if cache is still valid
if (age > CACHE_TTL) {
return null;
}

return cache;
}
catch {
return null;
}
}

/**
* Save version cache to disk
*/
async function saveCache(data: Omit<ZoteroVersionInfo, "lastUpdated">): Promise<void> {
await ensureDir(CACHE_DIR);
const cache: ZoteroVersionInfo = {
lastUpdated: Date.now(),
...data,
};
await writeJSON(CACHE_FILE, cache, { spaces: 2 });
}

/**
* Get Zotero version information with caching
*/
export async function getZoteroVersionInfo(): Promise<ZoteroVersionInfo> {
// Try to load from cache first
const cachedData = await loadCache();
if (cachedData) {
return cachedData;
}

// Fetch from API if cache is not available
const freshData = await fetchVersionsFromAPI();
await saveCache(freshData);

return {
lastUpdated: Date.now(),
...freshData,
};
}

/**
* Parse version string to extract major version
* E.g., "9.0-beta.21+1a89239a1" -> 9, "9.0" -> 9
*/
export function parseMajorVersion(version: string): number {
const match = version.match(/^(\d+)/);
if (!match) {
throw new Error(`Invalid version format: ${version}`);
}
return Number.parseInt(match[1], 10);
}

/**
* Compare two version strings
* Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
*/
export function compareVersions(v1: string, v2: string): number {
const v1Parts = v1.split(/[.-]/).map((p) => {
const num = Number.parseInt(p, 10);
return Number.isNaN(num) ? 0 : num;
});
const v2Parts = v2.split(/[.-]/).map((p) => {
const num = Number.parseInt(p, 10);
return Number.isNaN(num) ? 0 : num;
});

for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
const p1 = v1Parts[i] ?? 0;
const p2 = v2Parts[i] ?? 0;

if (p1 < p2)
return -1;
if (p1 > p2)
return 1;
}

return 0;
}
Loading