Spaces:
Paused
Paused
icebear icebear0828 commited on
fix: write runtime cache to data/ instead of git-tracked config/ (#138)
Browse filesmodel-store synced merged catalog back to config/models.yaml on every
backend fetch (~1h), and update-checker mutated config/default.yaml on
version detection. Both made the repo dirty after normal runtime.
- syncStaticModels() → data/models-cache.yaml (gitignored)
- applyVersionUpdate() → data/version-state.json (gitignored)
- loadStaticModels() overlays cache on cold start for fallback
- loadCurrentConfig() checks version override before YAML baseline
- config/ is now read-only for runtime operations
Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
- CHANGELOG.md +5 -0
- src/__tests__/update-checker.test.ts +129 -0
- src/models/__tests__/model-cache.test.ts +71 -0
- src/models/model-store.ts +45 -13
- src/update-checker.ts +33 -7
CHANGELOG.md
CHANGED
|
@@ -18,6 +18,11 @@
|
|
| 18 |
|
| 19 |
### Fixed
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
- Responses SSE 新事件(`response.output_item.added` with `item.type=message`、`response.content_part.added/done`)未被识别,导致 `[CodexEvents] Unknown event` 日志刷屏
|
| 22 |
- 新模型(如 `gpt-5.4-mini`)无法被动态发现的问题
|
| 23 |
- 移除 `isCodexCompatibleId()` 白名单过滤,信任后端 `/codex/models` 返回
|
|
|
|
| 18 |
|
| 19 |
### Fixed
|
| 20 |
|
| 21 |
+
- 运行时缓存(模型目录同步、版本检测结果)直接写入 git 跟踪的 `config/` 文件,导致仓库频繁变脏
|
| 22 |
+
- `model-store` 的 `syncStaticModels()` 改写 `data/models-cache.yaml`(gitignored)
|
| 23 |
+
- `update-checker` 的 `applyVersionUpdate()` 改写 `data/version-state.json`(gitignored)
|
| 24 |
+
- `config/` 目录现在对运行时操作只读,仅 admin API 设置变更例外
|
| 25 |
+
|
| 26 |
- Responses SSE 新事件(`response.output_item.added` with `item.type=message`、`response.content_part.added/done`)未被识别,导致 `[CodexEvents] Unknown event` 日志刷屏
|
| 27 |
- 新模型(如 `gpt-5.4-mini`)无法被动态发现的问题
|
| 28 |
- 移除 `isCodexCompatibleId()` 白名单过滤,信任后端 `/codex/models` 返回
|
src/__tests__/update-checker.test.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests that update-checker writes version state to data/ (gitignored),
|
| 3 |
+
* NOT to config/default.yaml (git-tracked).
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
| 7 |
+
|
| 8 |
+
const mockWriteFileSync = vi.fn();
|
| 9 |
+
const mockExistsSync = vi.fn(() => false);
|
| 10 |
+
const mockMkdirSync = vi.fn();
|
| 11 |
+
const mockReadFileSync = vi.fn();
|
| 12 |
+
const mockMutateClientConfig = vi.fn();
|
| 13 |
+
|
| 14 |
+
vi.mock("../config.js", () => ({
|
| 15 |
+
mutateClientConfig: mockMutateClientConfig,
|
| 16 |
+
reloadAllConfigs: vi.fn(),
|
| 17 |
+
}));
|
| 18 |
+
|
| 19 |
+
vi.mock("../paths.js", () => ({
|
| 20 |
+
getConfigDir: vi.fn(() => "/fake/config"),
|
| 21 |
+
getDataDir: vi.fn(() => "/fake/data"),
|
| 22 |
+
isEmbedded: vi.fn(() => false),
|
| 23 |
+
}));
|
| 24 |
+
|
| 25 |
+
vi.mock("../utils/jitter.js", () => ({
|
| 26 |
+
jitterInt: vi.fn((ms: number) => ms),
|
| 27 |
+
}));
|
| 28 |
+
|
| 29 |
+
vi.mock("../tls/curl-fetch.js", () => ({
|
| 30 |
+
curlFetchGet: vi.fn(),
|
| 31 |
+
}));
|
| 32 |
+
|
| 33 |
+
vi.mock("fs", async (importOriginal) => {
|
| 34 |
+
const actual = await importOriginal<typeof import("fs")>();
|
| 35 |
+
return {
|
| 36 |
+
...actual,
|
| 37 |
+
readFileSync: mockReadFileSync,
|
| 38 |
+
writeFileSync: mockWriteFileSync,
|
| 39 |
+
existsSync: mockExistsSync,
|
| 40 |
+
mkdirSync: mockMkdirSync,
|
| 41 |
+
};
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
vi.mock("js-yaml", () => ({
|
| 45 |
+
default: {
|
| 46 |
+
load: vi.fn(() => ({
|
| 47 |
+
client: { app_version: "1.0.0", build_number: "100" },
|
| 48 |
+
})),
|
| 49 |
+
},
|
| 50 |
+
}));
|
| 51 |
+
|
| 52 |
+
import { curlFetchGet } from "../tls/curl-fetch.js";
|
| 53 |
+
|
| 54 |
+
const APPCAST_XML = `<?xml version="1.0"?>
|
| 55 |
+
<rss><channel><item>
|
| 56 |
+
<enclosure sparkle:shortVersionString="2.0.0" sparkle:version="200" url="https://example.com/download"/>
|
| 57 |
+
</item></channel></rss>`;
|
| 58 |
+
|
| 59 |
+
describe("update-checker writes to data/, not config/", () => {
|
| 60 |
+
beforeEach(() => {
|
| 61 |
+
vi.clearAllMocks();
|
| 62 |
+
mockExistsSync.mockReturnValue(false);
|
| 63 |
+
mockReadFileSync.mockReturnValue("");
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
it("applyVersionUpdate writes to data/version-state.json, not config/default.yaml", async () => {
|
| 67 |
+
vi.mocked(curlFetchGet).mockResolvedValue({
|
| 68 |
+
ok: true,
|
| 69 |
+
status: 200,
|
| 70 |
+
body: APPCAST_XML,
|
| 71 |
+
headers: {},
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
// Dynamic import to get fresh module state
|
| 75 |
+
const { checkForUpdate } = await import("../update-checker.js");
|
| 76 |
+
await checkForUpdate();
|
| 77 |
+
|
| 78 |
+
// Should write version-state.json to data/
|
| 79 |
+
const versionWrites = mockWriteFileSync.mock.calls.filter(
|
| 80 |
+
(call) => (call[0] as string).includes("version-state.json"),
|
| 81 |
+
);
|
| 82 |
+
expect(versionWrites.length).toBeGreaterThanOrEqual(1);
|
| 83 |
+
const writePath = versionWrites[0][0] as string;
|
| 84 |
+
expect(writePath).toBe("/fake/data/version-state.json");
|
| 85 |
+
|
| 86 |
+
// Parse the written content
|
| 87 |
+
const written = JSON.parse(versionWrites[0][1] as string) as {
|
| 88 |
+
app_version: string;
|
| 89 |
+
build_number: string;
|
| 90 |
+
};
|
| 91 |
+
expect(written.app_version).toBe("2.0.0");
|
| 92 |
+
expect(written.build_number).toBe("200");
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
it("never calls mutateYaml on config/default.yaml", async () => {
|
| 96 |
+
vi.mocked(curlFetchGet).mockResolvedValue({
|
| 97 |
+
ok: true,
|
| 98 |
+
status: 200,
|
| 99 |
+
body: APPCAST_XML,
|
| 100 |
+
headers: {},
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
const { checkForUpdate } = await import("../update-checker.js");
|
| 104 |
+
await checkForUpdate();
|
| 105 |
+
|
| 106 |
+
// No writes should target config/default.yaml
|
| 107 |
+
const configWrites = mockWriteFileSync.mock.calls.filter(
|
| 108 |
+
(call) => (call[0] as string).includes("/fake/config/"),
|
| 109 |
+
);
|
| 110 |
+
expect(configWrites).toHaveLength(0);
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
it("updates runtime config via mutateClientConfig", async () => {
|
| 114 |
+
vi.mocked(curlFetchGet).mockResolvedValue({
|
| 115 |
+
ok: true,
|
| 116 |
+
status: 200,
|
| 117 |
+
body: APPCAST_XML,
|
| 118 |
+
headers: {},
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
const { checkForUpdate } = await import("../update-checker.js");
|
| 122 |
+
await checkForUpdate();
|
| 123 |
+
|
| 124 |
+
expect(mockMutateClientConfig).toHaveBeenCalledWith({
|
| 125 |
+
app_version: "2.0.0",
|
| 126 |
+
build_number: "200",
|
| 127 |
+
});
|
| 128 |
+
});
|
| 129 |
+
});
|
src/models/__tests__/model-cache.test.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests that model-store writes cache to data/ (gitignored),
|
| 3 |
+
* NOT to config/models.yaml (git-tracked).
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
| 7 |
+
|
| 8 |
+
vi.mock("../../config.js", () => ({
|
| 9 |
+
getConfig: vi.fn(() => ({
|
| 10 |
+
server: {},
|
| 11 |
+
model: { default: "gpt-5.2-codex" },
|
| 12 |
+
api: { base_url: "https://chatgpt.com/backend-api" },
|
| 13 |
+
client: { app_version: "1.0.0" },
|
| 14 |
+
})),
|
| 15 |
+
}));
|
| 16 |
+
|
| 17 |
+
vi.mock("../../paths.js", () => ({
|
| 18 |
+
getConfigDir: vi.fn(() => "/fake/config"),
|
| 19 |
+
getDataDir: vi.fn(() => "/fake/data"),
|
| 20 |
+
}));
|
| 21 |
+
|
| 22 |
+
vi.mock("fs", async (importOriginal) => {
|
| 23 |
+
const actual = await importOriginal<typeof import("fs")>();
|
| 24 |
+
return {
|
| 25 |
+
...actual,
|
| 26 |
+
readFileSync: vi.fn(() => "models: []\naliases: {}"),
|
| 27 |
+
writeFileSync: vi.fn(),
|
| 28 |
+
writeFile: vi.fn((_p: string, _d: string, _e: string, cb: (err: Error | null) => void) => cb(null)),
|
| 29 |
+
existsSync: vi.fn(() => false),
|
| 30 |
+
mkdirSync: vi.fn(),
|
| 31 |
+
};
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
vi.mock("js-yaml", () => ({
|
| 35 |
+
default: {
|
| 36 |
+
load: vi.fn(() => ({ models: [], aliases: {} })),
|
| 37 |
+
dump: vi.fn(() => "models: []"),
|
| 38 |
+
},
|
| 39 |
+
}));
|
| 40 |
+
|
| 41 |
+
import { writeFile, mkdirSync, existsSync } from "fs";
|
| 42 |
+
import { loadStaticModels, applyBackendModels } from "../model-store.js";
|
| 43 |
+
|
| 44 |
+
describe("model cache writes to data/, not config/", () => {
|
| 45 |
+
beforeEach(() => {
|
| 46 |
+
vi.clearAllMocks();
|
| 47 |
+
vi.mocked(existsSync).mockReturnValue(false);
|
| 48 |
+
loadStaticModels();
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
it("syncStaticModels writes to data/models-cache.yaml", () => {
|
| 52 |
+
applyBackendModels([{ slug: "gpt-5.4", name: "GPT 5.4" }]);
|
| 53 |
+
|
| 54 |
+
expect(writeFile).toHaveBeenCalledOnce();
|
| 55 |
+
const writePath = vi.mocked(writeFile).mock.calls[0][0] as string;
|
| 56 |
+
expect(writePath).toContain("/fake/data/models-cache.yaml");
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it("syncStaticModels never writes to config/models.yaml", () => {
|
| 60 |
+
applyBackendModels([{ slug: "gpt-5.4", name: "GPT 5.4" }]);
|
| 61 |
+
|
| 62 |
+
const writePath = vi.mocked(writeFile).mock.calls[0][0] as string;
|
| 63 |
+
expect(writePath).not.toContain("/fake/config/");
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
it("ensures data dir exists before writing cache", () => {
|
| 67 |
+
applyBackendModels([{ slug: "gpt-5.4", name: "GPT 5.4" }]);
|
| 68 |
+
|
| 69 |
+
expect(mkdirSync).toHaveBeenCalledWith("/fake/data", { recursive: true });
|
| 70 |
+
});
|
| 71 |
+
});
|
src/models/model-store.ts
CHANGED
|
@@ -9,11 +9,11 @@
|
|
| 9 |
* Aliases always come from YAML (user-customizable), never from backend.
|
| 10 |
*/
|
| 11 |
|
| 12 |
-
import { readFileSync, writeFile } from "fs";
|
| 13 |
import { resolve } from "path";
|
| 14 |
import yaml from "js-yaml";
|
| 15 |
import { getConfig } from "../config.js";
|
| 16 |
-
import { getConfigDir } from "../paths.js";
|
| 17 |
|
| 18 |
export interface CodexModelInfo {
|
| 19 |
id: string;
|
|
@@ -60,6 +60,30 @@ export function loadStaticModels(configDir?: string): void {
|
|
| 60 |
_planModelMap = new Map(); // Reset plan maps on reload
|
| 61 |
_modelPlanIndex = new Map();
|
| 62 |
console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
// ── Backend merge ──────────────────────────────────────────────────
|
|
@@ -201,24 +225,26 @@ export function applyBackendModels(backendModels: BackendModelEntry[]): void {
|
|
| 201 |
}
|
| 202 |
|
| 203 |
/**
|
| 204 |
-
* Write the current merged catalog
|
| 205 |
-
*
|
|
|
|
|
|
|
| 206 |
*/
|
| 207 |
function syncStaticModels(): void {
|
| 208 |
-
const
|
|
|
|
| 209 |
const today = new Date().toISOString().slice(0, 10);
|
| 210 |
|
| 211 |
// Strip internal `source` field before serializing
|
| 212 |
const models = _catalog.map(({ source: _s, ...rest }) => rest);
|
| 213 |
|
| 214 |
const header = [
|
| 215 |
-
"# Codex model
|
| 216 |
"#",
|
| 217 |
-
"#
|
| 218 |
-
"#
|
| 219 |
-
"# 2. Dynamic — fetched from /backend-api/codex/models, auto-synced here",
|
| 220 |
"#",
|
| 221 |
-
`# Last updated: ${today}
|
| 222 |
"",
|
| 223 |
].join("\n");
|
| 224 |
|
|
@@ -227,11 +253,17 @@ function syncStaticModels(): void {
|
|
| 227 |
{ lineWidth: 120, noRefs: true, sortKeys: false },
|
| 228 |
);
|
| 229 |
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
if (err) {
|
| 232 |
-
console.warn(`[ModelStore] Failed to sync models
|
| 233 |
} else {
|
| 234 |
-
console.log(`[ModelStore] Synced ${models.length} models to models.yaml`);
|
| 235 |
}
|
| 236 |
});
|
| 237 |
}
|
|
|
|
| 9 |
* Aliases always come from YAML (user-customizable), never from backend.
|
| 10 |
*/
|
| 11 |
|
| 12 |
+
import { readFileSync, writeFile, existsSync, mkdirSync } from "fs";
|
| 13 |
import { resolve } from "path";
|
| 14 |
import yaml from "js-yaml";
|
| 15 |
import { getConfig } from "../config.js";
|
| 16 |
+
import { getConfigDir, getDataDir } from "../paths.js";
|
| 17 |
|
| 18 |
export interface CodexModelInfo {
|
| 19 |
id: string;
|
|
|
|
| 60 |
_planModelMap = new Map(); // Reset plan maps on reload
|
| 61 |
_modelPlanIndex = new Map();
|
| 62 |
console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);
|
| 63 |
+
|
| 64 |
+
// Overlay cached backend models from data/ (cold-start fallback)
|
| 65 |
+
try {
|
| 66 |
+
const cachePath = resolve(getDataDir(), "models-cache.yaml");
|
| 67 |
+
if (existsSync(cachePath)) {
|
| 68 |
+
const cached = yaml.load(readFileSync(cachePath, "utf-8")) as ModelsConfig;
|
| 69 |
+
const cachedModels = cached.models ?? [];
|
| 70 |
+
if (cachedModels.length > 0) {
|
| 71 |
+
const staticIds = new Set(_catalog.map((m) => m.id));
|
| 72 |
+
let added = 0;
|
| 73 |
+
for (const m of cachedModels) {
|
| 74 |
+
if (!staticIds.has(m.id)) {
|
| 75 |
+
_catalog.push({ ...m, source: "backend" as const });
|
| 76 |
+
added++;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
if (added > 0) {
|
| 80 |
+
console.log(`[ModelStore] Overlaid ${added} cached backend models from data/models-cache.yaml`);
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
} catch {
|
| 85 |
+
// Cache missing or corrupt — safe to ignore, backend fetch will repopulate
|
| 86 |
+
}
|
| 87 |
}
|
| 88 |
|
| 89 |
// ── Backend merge ──────────────────────────────────────────────────
|
|
|
|
| 225 |
}
|
| 226 |
|
| 227 |
/**
|
| 228 |
+
* Write the current merged catalog to data/models-cache.yaml so it serves
|
| 229 |
+
* as a fallback for future cold starts. Fire-and-forget.
|
| 230 |
+
*
|
| 231 |
+
* config/models.yaml stays read-only (git-tracked baseline).
|
| 232 |
*/
|
| 233 |
function syncStaticModels(): void {
|
| 234 |
+
const dataDir = getDataDir();
|
| 235 |
+
const cachePath = resolve(dataDir, "models-cache.yaml");
|
| 236 |
const today = new Date().toISOString().slice(0, 10);
|
| 237 |
|
| 238 |
// Strip internal `source` field before serializing
|
| 239 |
const models = _catalog.map(({ source: _s, ...rest }) => rest);
|
| 240 |
|
| 241 |
const header = [
|
| 242 |
+
"# Codex model cache",
|
| 243 |
"#",
|
| 244 |
+
"# Auto-synced by model-store from backend fetch results.",
|
| 245 |
+
"# This is a runtime cache — do NOT commit to git.",
|
|
|
|
| 246 |
"#",
|
| 247 |
+
`# Last updated: ${today}`,
|
| 248 |
"",
|
| 249 |
].join("\n");
|
| 250 |
|
|
|
|
| 253 |
{ lineWidth: 120, noRefs: true, sortKeys: false },
|
| 254 |
);
|
| 255 |
|
| 256 |
+
try {
|
| 257 |
+
mkdirSync(dataDir, { recursive: true });
|
| 258 |
+
} catch {
|
| 259 |
+
// already exists
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
writeFile(cachePath, header + body, "utf-8", (err) => {
|
| 263 |
if (err) {
|
| 264 |
+
console.warn(`[ModelStore] Failed to sync models cache: ${err.message}`);
|
| 265 |
} else {
|
| 266 |
+
console.log(`[ModelStore] Synced ${models.length} models to data/models-cache.yaml`);
|
| 267 |
}
|
| 268 |
});
|
| 269 |
}
|
src/update-checker.ts
CHANGED
|
@@ -3,14 +3,13 @@
|
|
| 3 |
* Automatically applies version updates to config file and runtime.
|
| 4 |
*/
|
| 5 |
|
| 6 |
-
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
| 7 |
import { resolve } from "path";
|
| 8 |
import { fork } from "child_process";
|
| 9 |
import yaml from "js-yaml";
|
| 10 |
import { mutateClientConfig, reloadAllConfigs } from "./config.js";
|
| 11 |
import { jitterInt } from "./utils/jitter.js";
|
| 12 |
import { curlFetchGet } from "./tls/curl-fetch.js";
|
| 13 |
-
import { mutateYaml } from "./utils/yaml-mutate.js";
|
| 14 |
import { getConfigDir, getDataDir, isEmbedded } from "./paths.js";
|
| 15 |
|
| 16 |
function getConfigPath(): string {
|
|
@@ -36,7 +35,27 @@ let _currentState: UpdateState | null = null;
|
|
| 36 |
let _pollTimer: ReturnType<typeof setTimeout> | null = null;
|
| 37 |
let _updateInProgress = false;
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
function loadCurrentConfig(): { app_version: string; build_number: string } {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const raw = yaml.load(readFileSync(getConfigPath(), "utf-8")) as Record<string, unknown>;
|
| 41 |
const client = raw.client as Record<string, string>;
|
| 42 |
return {
|
|
@@ -69,11 +88,18 @@ function parseAppcast(xml: string): {
|
|
| 69 |
}
|
| 70 |
|
| 71 |
function applyVersionUpdate(version: string, build: string): void {
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
mutateClientConfig({ app_version: version, build_number: build });
|
| 78 |
}
|
| 79 |
|
|
|
|
| 3 |
* Automatically applies version updates to config file and runtime.
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
| 7 |
import { resolve } from "path";
|
| 8 |
import { fork } from "child_process";
|
| 9 |
import yaml from "js-yaml";
|
| 10 |
import { mutateClientConfig, reloadAllConfigs } from "./config.js";
|
| 11 |
import { jitterInt } from "./utils/jitter.js";
|
| 12 |
import { curlFetchGet } from "./tls/curl-fetch.js";
|
|
|
|
| 13 |
import { getConfigDir, getDataDir, isEmbedded } from "./paths.js";
|
| 14 |
|
| 15 |
function getConfigPath(): string {
|
|
|
|
| 35 |
let _pollTimer: ReturnType<typeof setTimeout> | null = null;
|
| 36 |
let _updateInProgress = false;
|
| 37 |
|
| 38 |
+
function getVersionOverridePath(): string {
|
| 39 |
+
return resolve(getDataDir(), "version-state.json");
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
function loadCurrentConfig(): { app_version: string; build_number: string } {
|
| 43 |
+
// Check runtime override first (written by applyVersionUpdate)
|
| 44 |
+
try {
|
| 45 |
+
const overridePath = getVersionOverridePath();
|
| 46 |
+
if (existsSync(overridePath)) {
|
| 47 |
+
const override = JSON.parse(readFileSync(overridePath, "utf-8")) as {
|
| 48 |
+
app_version: string;
|
| 49 |
+
build_number: string;
|
| 50 |
+
};
|
| 51 |
+
if (override.app_version && override.build_number) {
|
| 52 |
+
return override;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
} catch {
|
| 56 |
+
// Corrupt override — fall through to YAML baseline
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
const raw = yaml.load(readFileSync(getConfigPath(), "utf-8")) as Record<string, unknown>;
|
| 60 |
const client = raw.client as Record<string, string>;
|
| 61 |
return {
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
function applyVersionUpdate(version: string, build: string): void {
|
| 91 |
+
// Persist to data/ (gitignored) — config/default.yaml stays read-only
|
| 92 |
+
try {
|
| 93 |
+
const dataDir = getDataDir();
|
| 94 |
+
mkdirSync(dataDir, { recursive: true });
|
| 95 |
+
writeFileSync(
|
| 96 |
+
getVersionOverridePath(),
|
| 97 |
+
JSON.stringify({ app_version: version, build_number: build }, null, 2),
|
| 98 |
+
);
|
| 99 |
+
} catch {
|
| 100 |
+
// best-effort persistence
|
| 101 |
+
}
|
| 102 |
+
// Update runtime config in memory
|
| 103 |
mutateClientConfig({ app_version: version, build_number: build });
|
| 104 |
}
|
| 105 |
|