Skip to content

Commit 597f429

Browse files
committed
stage
1 parent ffca4ba commit 597f429

6 files changed

Lines changed: 333 additions & 2 deletions

File tree

src/cli.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Context, OverrideConfig } from "./types/index.js";
33
import process from "node:process";
44
import { Command } from "commander";
55
import { name, version } from "../package.json" with { type: "json" };
6-
import { Build, Config, Release, Serve, Test } from "./index.js";
6+
import { Build, Config, ManifestCommand, Release, Serve, Test } from "./index.js";
77
import { checkGitIgnore } from "./utils/gitignore.js";
88
import { logger } from "./utils/logger.js";
99
import { updateNotifier } from "./utils/updater.js";
@@ -92,6 +92,16 @@ async function main() {
9292
});
9393
});
9494

95+
cli
96+
.command("manifest:update-max-version")
97+
.description("Update strict_max_version in manifest.json")
98+
.argument("[path]", "Path to manifest.json file")
99+
.action(async (path) => {
100+
const ctx = await Config.loadConfig({});
101+
const instance = new ManifestCommand(ctx);
102+
await instance.updateMaxVersion(path);
103+
});
104+
95105
cli.arguments("<command>").action((cmd) => {
96106
cli.outputHelp();
97107
logger.error(`Unknown command name "${cmd}".`);

src/core/builder/manifest.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,39 @@ import type { Manifest } from "../../types/manifest.js";
33
import { toMerged } from "es-toolkit";
44
import { outputJSON, pathExists, readJSON } from "fs-extra/esm";
55
import { logger } from "../../utils/logger.js";
6+
import { compareVersions, getZoteroVersionInfo, parseMajorVersion } from "../../utils/zotero-version.js";
7+
8+
/**
9+
* Check and update strict_max_version in manifest
10+
*/
11+
async function checkStrictMaxVersion(manifest: Manifest): Promise<void> {
12+
try {
13+
const versionInfo = await getZoteroVersionInfo();
14+
const latestBeta = versionInfo.beta.mac; // All platforms have the same version
15+
const latestRelease = versionInfo.release.mac;
16+
17+
const currentMaxVersion = manifest.applications?.zotero?.strict_max_version;
18+
19+
if (!currentMaxVersion) {
20+
// Auto-fill with latest beta major version + 1
21+
const majorVersion = parseMajorVersion(latestBeta);
22+
const autoFilledVersion = `${majorVersion + 1}.0`;
23+
manifest.applications.zotero.strict_max_version = autoFilledVersion;
24+
logger.info(`Auto-filled strict_max_version to ${autoFilledVersion} (latest beta: ${latestBeta})`);
25+
}
26+
else {
27+
// Check if current version is outdated
28+
if (compareVersions(currentMaxVersion, latestRelease) < 0) {
29+
logger.warn(`strict_max_version (${currentMaxVersion}) is older than the latest Zotero release (${latestRelease})`);
30+
logger.warn(`To update, run: npx zotero-plugin manifest:update-max-version`);
31+
}
32+
}
33+
}
34+
catch (error) {
35+
logger.debug(`Failed to check Zotero version: ${error}`);
36+
// Don't fail the build if version check fails
37+
}
38+
}
639

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

3063
const data: Manifest = toMerged(userData, template);
64+
65+
// Check and update strict_max_version
66+
await checkStrictMaxVersion(data);
67+
3168
logger.debug(`manifest: ${JSON.stringify(data, null, 2)}`);
3269

3370
outputJSON(manifestPath, data, { spaces: 2 });

src/core/manifest-command.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { Manifest } from "../types/manifest.js";
2+
import { basename } from "node:path";
3+
import process from "node:process";
4+
import { pathExists, readJSON, writeJSON } from "fs-extra/esm";
5+
import { getZoteroVersionInfo, parseMajorVersion } from "../utils/zotero-version.js";
6+
import { Base } from "./base.js";
7+
8+
export default class ManifestCommand extends Base {
9+
/**
10+
* Update strict_max_version in manifest.json
11+
*/
12+
async updateMaxVersion(manifestPath?: string): Promise<void> {
13+
// Determine the manifest file path
14+
let targetPath: string | null = null;
15+
if (manifestPath) {
16+
targetPath = manifestPath;
17+
}
18+
else {
19+
// Search for manifest.json in common locations
20+
const possiblePaths = [
21+
"manifest.json",
22+
"src/manifest.json",
23+
"addon/manifest.json",
24+
];
25+
26+
for (const p of possiblePaths) {
27+
if (await pathExists(p)) {
28+
targetPath = p;
29+
break;
30+
}
31+
}
32+
33+
if (!targetPath) {
34+
this.logger.error("manifest.json not found in current directory or common locations");
35+
this.logger.error("Please specify the path to manifest.json");
36+
process.exit(1);
37+
}
38+
}
39+
40+
if (!await pathExists(targetPath)) {
41+
this.logger.error(`manifest.json not found at ${targetPath}`);
42+
process.exit(1);
43+
}
44+
45+
try {
46+
const manifest = await readJSON(targetPath) as Manifest;
47+
48+
// Fetch latest version info
49+
const versionInfo = await getZoteroVersionInfo();
50+
const latestBeta = versionInfo.beta.mac;
51+
52+
// Calculate new version
53+
const majorVersion = parseMajorVersion(latestBeta);
54+
const newMaxVersion = `${majorVersion + 1}.0`;
55+
56+
// Update manifest
57+
if (!manifest.applications) {
58+
manifest.applications = { zotero: { id: "", update_url: "" } };
59+
}
60+
if (!manifest.applications.zotero) {
61+
manifest.applications.zotero = { id: "", update_url: "" };
62+
}
63+
64+
const oldMaxVersion = manifest.applications.zotero.strict_max_version;
65+
manifest.applications.zotero.strict_max_version = newMaxVersion;
66+
67+
// Write back to file
68+
await writeJSON(targetPath, manifest, { spaces: 2 });
69+
70+
this.logger.success(`Updated strict_max_version in ${basename(targetPath)}`);
71+
if (oldMaxVersion) {
72+
this.logger.info(` ${oldMaxVersion} -> ${newMaxVersion}`);
73+
}
74+
else {
75+
this.logger.info(` Added: ${newMaxVersion}`);
76+
}
77+
this.logger.info(`Latest Zotero beta: ${latestBeta}`);
78+
}
79+
catch (error) {
80+
this.logger.error(`Failed to update manifest: ${error}`);
81+
process.exit(1);
82+
}
83+
}
84+
85+
async run(): Promise<void> {
86+
// This is a no-op, subcommands should be called directly
87+
this.logger.info("Use 'zotero-plugin manifest:update-max-version' to update strict_max_version");
88+
}
89+
90+
exit(): void {
91+
// No cleanup needed
92+
}
93+
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defineConfig, loadConfig } from "./config.js";
22
import Build from "./core/builder/index.js";
3+
import ManifestCommand from "./core/manifest-command.js";
34
import Release from "./core/releaser/index.js";
45
import Serve from "./core/server.js";
56
import Test from "./core/tester/index.js";
@@ -12,4 +13,4 @@ const Config: {
1213
loadConfig,
1314
};
1415

15-
export { Build, Config, defineConfig, Release, Serve, Test };
16+
export { Build, Config, defineConfig, ManifestCommand, Release, Serve, Test };

src/utils/zotero-version.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from "vitest";
2+
import { compareVersions, parseMajorVersion } from "./zotero-version.js";
3+
4+
describe("zotero-version utilities", () => {
5+
describe("parseMajorVersion", () => {
6+
it("should parse beta version", () => {
7+
expect(parseMajorVersion("9.0-beta.21+1a89239a1")).toBe(9);
8+
});
9+
10+
it("should parse release version", () => {
11+
expect(parseMajorVersion("9.0")).toBe(9);
12+
});
13+
14+
it("should handle single digit version", () => {
15+
expect(parseMajorVersion("10.0")).toBe(10);
16+
});
17+
18+
it("should throw on invalid format", () => {
19+
expect(() => parseMajorVersion("invalid")).toThrow("Invalid version format");
20+
});
21+
});
22+
23+
describe("compareVersions", () => {
24+
it("should return -1 when v1 < v2", () => {
25+
expect(compareVersions("8.0", "9.0")).toBe(-1);
26+
});
27+
28+
it("should return 0 when v1 === v2", () => {
29+
expect(compareVersions("9.0", "9.0")).toBe(0);
30+
});
31+
32+
it("should return 1 when v1 > v2", () => {
33+
expect(compareVersions("9.0", "8.0")).toBe(1);
34+
});
35+
36+
it("should handle beta versions", () => {
37+
expect(compareVersions("9.0-beta.20", "9.0-beta.21")).toBe(-1);
38+
});
39+
40+
it("should handle mixed version formats", () => {
41+
expect(compareVersions("9.0", "9.0-beta.21")).toBe(-1);
42+
});
43+
44+
it("should handle different lengths", () => {
45+
expect(compareVersions("9", "9.0.1")).toBe(-1);
46+
});
47+
});
48+
});

src/utils/zotero-version.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { homedir } from "node:os";
2+
import { join } from "node:path";
3+
import { ensureDir, pathExists, readJSON, writeJSON } from "fs-extra/esm";
4+
5+
/**
6+
* Platform identifier for version information
7+
*/
8+
export type Platform = "mac" | "win-x64" | "win-arm64" | "win32" | "linux-x86_64" | "linux-i686" | "linux-arm64";
9+
10+
/**
11+
* Version info structure
12+
*/
13+
export interface ZoteroVersionInfo {
14+
lastUpdated: number;
15+
release: Record<Platform, string>;
16+
beta: Record<Platform, string>;
17+
}
18+
19+
const CACHE_DIR = join(homedir(), ".scaffold", "cache");
20+
const CACHE_FILE = join(CACHE_DIR, "zotero-version.json");
21+
const CACHE_TTL = 3 * 24 * 60 * 60 * 1000; // 3 days in milliseconds
22+
const API_BETA = "https://www.zotero.org/download/client/version?channel=beta";
23+
const API_RELEASE = "https://www.zotero.org/download/client/version?channel=release";
24+
25+
/**
26+
* Fetch Zotero version information from API
27+
*/
28+
async function fetchVersionsFromAPI(): Promise<Omit<ZoteroVersionInfo, "lastUpdated">> {
29+
const [betaRes, releaseRes] = await Promise.all([
30+
fetch(API_BETA),
31+
fetch(API_RELEASE),
32+
]);
33+
34+
if (!betaRes.ok || !releaseRes.ok) {
35+
throw new Error("Failed to fetch Zotero version information from API");
36+
}
37+
38+
const [beta, release] = await Promise.all([
39+
betaRes.json(),
40+
releaseRes.json(),
41+
]);
42+
43+
return {
44+
beta: beta as Record<Platform, string>,
45+
release: release as Record<Platform, string>,
46+
};
47+
}
48+
49+
/**
50+
* Load version cache from disk
51+
*/
52+
async function loadCache(): Promise<ZoteroVersionInfo | null> {
53+
try {
54+
if (!await pathExists(CACHE_FILE)) {
55+
return null;
56+
}
57+
58+
const cache = await readJSON(CACHE_FILE) as ZoteroVersionInfo;
59+
const age = Date.now() - cache.lastUpdated;
60+
61+
// Check if cache is still valid
62+
if (age > CACHE_TTL) {
63+
return null;
64+
}
65+
66+
return cache;
67+
}
68+
catch {
69+
return null;
70+
}
71+
}
72+
73+
/**
74+
* Save version cache to disk
75+
*/
76+
async function saveCache(data: Omit<ZoteroVersionInfo, "lastUpdated">): Promise<void> {
77+
await ensureDir(CACHE_DIR);
78+
const cache: ZoteroVersionInfo = {
79+
lastUpdated: Date.now(),
80+
...data,
81+
};
82+
await writeJSON(CACHE_FILE, cache, { spaces: 2 });
83+
}
84+
85+
/**
86+
* Get Zotero version information with caching
87+
*/
88+
export async function getZoteroVersionInfo(): Promise<ZoteroVersionInfo> {
89+
// Try to load from cache first
90+
const cachedData = await loadCache();
91+
if (cachedData) {
92+
return cachedData;
93+
}
94+
95+
// Fetch from API if cache is not available
96+
const freshData = await fetchVersionsFromAPI();
97+
await saveCache(freshData);
98+
99+
return {
100+
lastUpdated: Date.now(),
101+
...freshData,
102+
};
103+
}
104+
105+
/**
106+
* Parse version string to extract major version
107+
* E.g., "9.0-beta.21+1a89239a1" -> 9, "9.0" -> 9
108+
*/
109+
export function parseMajorVersion(version: string): number {
110+
const match = version.match(/^(\d+)/);
111+
if (!match) {
112+
throw new Error(`Invalid version format: ${version}`);
113+
}
114+
return Number.parseInt(match[1], 10);
115+
}
116+
117+
/**
118+
* Compare two version strings
119+
* Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
120+
*/
121+
export function compareVersions(v1: string, v2: string): number {
122+
const v1Parts = v1.split(/[.-]/).map((p) => {
123+
const num = Number.parseInt(p, 10);
124+
return Number.isNaN(num) ? 0 : num;
125+
});
126+
const v2Parts = v2.split(/[.-]/).map((p) => {
127+
const num = Number.parseInt(p, 10);
128+
return Number.isNaN(num) ? 0 : num;
129+
});
130+
131+
for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
132+
const p1 = v1Parts[i] ?? 0;
133+
const p2 = v2Parts[i] ?? 0;
134+
135+
if (p1 < p2)
136+
return -1;
137+
if (p1 > p2)
138+
return 1;
139+
}
140+
141+
return 0;
142+
}

0 commit comments

Comments
 (0)