@pi-unipi/updater 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @pi-unipi/updater — Extension entry point
3
+ *
4
+ * Auto-updater, changelog browser, and readme browser for Unipi.
5
+ *
6
+ * On session start: loads config, checks npm registry for updates,
7
+ * shows update overlay if available. Registers commands for
8
+ * /unipi:readme, /unipi:changelog, /unipi:updater-settings.
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import {
13
+ UNIPI_EVENTS,
14
+ MODULES,
15
+ UPDATER_COMMANDS,
16
+ UNIPI_PREFIX,
17
+ emitEvent,
18
+ getPackageVersion,
19
+ } from "@pi-unipi/core";
20
+ import { registerCommands } from "./commands.js";
21
+ import { loadConfig } from "./settings.js";
22
+ import { checkForUpdates } from "./checker.js";
23
+ import { isVersionSkipped } from "./cache.js";
24
+ import { renderUpdateOverlay } from "./tui/update-overlay.js";
25
+
26
+ /** Package version */
27
+ const VERSION = getPackageVersion(new URL("..", import.meta.url).pathname);
28
+
29
+ export default function updaterExtension(pi: ExtensionAPI): void {
30
+ // Register skills directory
31
+ const skillsDir = new URL("../skills", import.meta.url).pathname;
32
+ pi.on("resources_discover", async () => {
33
+ return {
34
+ skillPaths: [skillsDir],
35
+ };
36
+ });
37
+
38
+ // Register commands
39
+ registerCommands(pi);
40
+
41
+ // Session lifecycle — check for updates and announce module
42
+ pi.on("session_start", async (_event, ctx) => {
43
+ // Emit MODULE_READY
44
+ emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
45
+ name: MODULES.UPDATER,
46
+ version: VERSION,
47
+ commands: [
48
+ `${UNIPI_PREFIX}${UPDATER_COMMANDS.README}`,
49
+ `${UNIPI_PREFIX}${UPDATER_COMMANDS.CHANGELOG}`,
50
+ `${UNIPI_PREFIX}${UPDATER_COMMANDS.UPDATER_SETTINGS}`,
51
+ ],
52
+ tools: [],
53
+ });
54
+
55
+ // Register info-screen group
56
+ const infoRegistry = (globalThis as any).__unipi_info_registry;
57
+ if (infoRegistry) {
58
+ let cachedResult: { currentVersion: string; latestVersion: string; updateAvailable: boolean; lastCheck: string } | null = null;
59
+
60
+ infoRegistry.registerGroup({
61
+ id: "updater",
62
+ name: "Updater",
63
+ icon: "📦",
64
+ priority: 20,
65
+ config: {
66
+ showByDefault: true,
67
+ stats: [
68
+ { id: "current", label: "Installed", show: true },
69
+ { id: "latest", label: "Latest", show: true },
70
+ { id: "status", label: "Status", show: true },
71
+ { id: "lastCheck", label: "Last check", show: true },
72
+ ],
73
+ },
74
+ dataProvider: async () => {
75
+ if (!cachedResult) {
76
+ return {
77
+ current: VERSION,
78
+ latest: "checking...",
79
+ status: "⏳ Checking",
80
+ lastCheck: "never",
81
+ };
82
+ }
83
+ return {
84
+ current: cachedResult.currentVersion,
85
+ latest: cachedResult.latestVersion,
86
+ status: cachedResult.updateAvailable ? "↑ Update available" : "✓ Up to date",
87
+ lastCheck: cachedResult.lastCheck || "never",
88
+ };
89
+ },
90
+ });
91
+
92
+ // Subscribe to events to update cached data
93
+ pi.events.on(UNIPI_EVENTS.UPDATE_CHECK, (payload: any) => {
94
+ cachedResult = {
95
+ currentVersion: payload.currentVersion,
96
+ latestVersion: payload.latestVersion,
97
+ updateAvailable: payload.updateAvailable,
98
+ lastCheck: new Date().toLocaleTimeString(),
99
+ };
100
+ emitEvent(pi as any, UNIPI_EVENTS.INFO_DATA_UPDATED, {
101
+ groupId: "updater",
102
+ keys: ["current", "latest", "status", "lastCheck"],
103
+ });
104
+ });
105
+
106
+ pi.events.on(UNIPI_EVENTS.UPDATE_AVAILABLE, (_payload: any) => {
107
+ if (cachedResult) {
108
+ cachedResult.updateAvailable = true;
109
+ }
110
+ emitEvent(pi as any, UNIPI_EVENTS.INFO_DATA_UPDATED, {
111
+ groupId: "updater",
112
+ keys: ["status"],
113
+ });
114
+ });
115
+
116
+ pi.events.on(UNIPI_EVENTS.UPDATE_APPLIED, (_payload: any) => {
117
+ if (cachedResult) {
118
+ cachedResult.updateAvailable = false;
119
+ }
120
+ emitEvent(pi as any, UNIPI_EVENTS.INFO_DATA_UPDATED, {
121
+ groupId: "updater",
122
+ keys: ["status"],
123
+ });
124
+ });
125
+ }
126
+
127
+ // Check for updates in background
128
+ const config = loadConfig();
129
+ if (config.autoUpdate === "disabled") return;
130
+
131
+ try {
132
+ const result = await checkForUpdates();
133
+
134
+ // Emit check event
135
+ emitEvent(pi as any, UNIPI_EVENTS.UPDATE_CHECK, result);
136
+
137
+ if (!result.updateAvailable || result.error) return;
138
+
139
+ // Check if user skipped this version
140
+ if (isVersionSkipped(result.latestVersion)) return;
141
+
142
+ // Emit available event
143
+ emitEvent(pi as any, UNIPI_EVENTS.UPDATE_AVAILABLE, {
144
+ currentVersion: result.currentVersion,
145
+ latestVersion: result.latestVersion,
146
+ });
147
+
148
+ // Show update overlay if UI is available
149
+ if (ctx.hasUI) {
150
+ const updateResult = await ctx.ui.custom(
151
+ renderUpdateOverlay(result),
152
+ {
153
+ overlay: true,
154
+ overlayOptions: {
155
+ width: "80%",
156
+ minWidth: 60,
157
+ anchor: "center",
158
+ margin: 2,
159
+ },
160
+ },
161
+ );
162
+ if (updateResult?.updated) {
163
+ ctx.ui.notify(
164
+ `Updated to ${result.latestVersion}. Restart pi to apply.`,
165
+ "info",
166
+ );
167
+ }
168
+ }
169
+ } catch (_err) {
170
+ // Update check failure — silent, non-critical
171
+ }
172
+ });
173
+
174
+ // Cleanup on session shutdown
175
+ pi.on("session_shutdown", async () => {
176
+ // No cleanup needed
177
+ });
178
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @pi-unipi/updater — Update installer
3
+ *
4
+ * Wraps child_process.exec for installing updates via pi CLI.
5
+ * Emits UPDATE_APPLIED or UPDATE_ERROR events.
6
+ */
7
+
8
+ import { exec } from "child_process";
9
+ import { promisify } from "util";
10
+ import { getPackageVersion, emitEvent, UNIPI_EVENTS } from "@pi-unipi/core";
11
+ import type { InstallResult } from "../types.js";
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ /** Timeout for the install command (60 seconds) */
16
+ const INSTALL_TIMEOUT_MS = 60000;
17
+
18
+ /**
19
+ * Install the latest version of @pi-unipi/unipi.
20
+ * Uses pi CLI: `pi install npm:@pi-unipi/unipi`
21
+ * Returns structured result with success/failure info.
22
+ */
23
+ export async function installUpdate(
24
+ pi?: { events: { emit: (name: string, payload: unknown) => void } },
25
+ ): Promise<InstallResult> {
26
+ const installedBefore = getPackageVersion(
27
+ new URL("../../..", import.meta.url).pathname,
28
+ );
29
+
30
+ try {
31
+ const { stdout, stderr } = await execAsync(
32
+ "pi install npm:@pi-unipi/unipi",
33
+ {
34
+ timeout: INSTALL_TIMEOUT_MS,
35
+ env: { ...process.env },
36
+ },
37
+ );
38
+
39
+ // Get new version after install
40
+ const installedAfter = getPackageVersion(
41
+ new URL("../../..", import.meta.url).pathname,
42
+ );
43
+
44
+ const result: InstallResult = {
45
+ success: true,
46
+ version: installedAfter,
47
+ };
48
+
49
+ // Emit success event
50
+ if (pi) {
51
+ emitEvent(pi, UNIPI_EVENTS.UPDATE_APPLIED, {
52
+ previousVersion: installedBefore,
53
+ newVersion: installedAfter,
54
+ });
55
+ }
56
+
57
+ return result;
58
+ } catch (err: any) {
59
+ const errorMessage = err.stderr || err.message || "Unknown install error";
60
+
61
+ // Emit error event
62
+ if (pi) {
63
+ emitEvent(pi, UNIPI_EVENTS.UPDATE_ERROR, {
64
+ error: errorMessage,
65
+ phase: "install",
66
+ });
67
+ }
68
+
69
+ return {
70
+ success: false,
71
+ error: errorMessage,
72
+ };
73
+ }
74
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @pi-unipi/updater — Markdown terminal renderer
3
+ *
4
+ * Renders markdown to terminal-formatted strings.
5
+ * When a Theme is provided, uses the full Markdown component from pi-tui
6
+ * with theme-aware styling. Falls back to simple ANSI rendering otherwise.
7
+ */
8
+
9
+ import { Markdown } from "@mariozechner/pi-tui";
10
+ import type { MarkdownTheme } from "@mariozechner/pi-tui";
11
+ import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
12
+ import type { Theme } from "@mariozechner/pi-coding-agent";
13
+
14
+ /**
15
+ * Render markdown text to terminal-formatted lines.
16
+ *
17
+ * When a Theme is provided, uses the full Markdown component from pi-tui
18
+ * with syntax highlighting, proper list nesting, tables, etc.
19
+ *
20
+ * @param text - Markdown text to render
21
+ * @param width - Available width for rendering
22
+ * @param theme - Optional Theme for styled rendering
23
+ * @returns Array of rendered terminal lines
24
+ */
25
+ export function renderMarkdown(text: string, width: number, theme?: Theme): string[] {
26
+ if (theme) {
27
+ return renderWithTheme(text, width, theme);
28
+ }
29
+ return renderSimple(text, width);
30
+ }
31
+
32
+ /**
33
+ * Render using the full Markdown component with theme support.
34
+ */
35
+ function renderWithTheme(text: string, width: number, theme: Theme): string[] {
36
+ const mdTheme = getMarkdownTheme();
37
+ const md = new Markdown(text, 1, 0, mdTheme);
38
+ return md.render(width);
39
+ }
40
+
41
+ /**
42
+ * Simple fallback renderer using basic ANSI codes.
43
+ * Used when no Theme is available.
44
+ */
45
+
46
+ /** ANSI escape codes */
47
+ const ESC = "\x1b";
48
+ const BOLD = `${ESC}[1m`;
49
+ const DIM = `${ESC}[2m`;
50
+ const UNDERLINE = `${ESC}[4m`;
51
+ const RESET = `${ESC}[0m`;
52
+
53
+ /** Wrap text in ANSI formatting */
54
+ function fmt(code: string, text: string): string {
55
+ return `${code}${text}${RESET}`;
56
+ }
57
+
58
+ /** Word-wrap a line to fit within width */
59
+ function wordWrap(line: string, width: number): string[] {
60
+ // Strip ANSI for length calculation but preserve in output
61
+ const stripped = line.replace(/\x1b\[[0-9;]*m/g, "");
62
+ if (stripped.length <= width) return [line];
63
+
64
+ const words = line.split(/(\s+)/);
65
+ const result: string[] = [];
66
+ let currentLine = "";
67
+ let currentWidth = 0;
68
+
69
+ for (const word of words) {
70
+ const wordWidth = word.replace(/\x1b\[[0-9;]*m/g, "").length;
71
+ if (currentWidth + wordWidth > width && currentLine) {
72
+ result.push(currentLine);
73
+ currentLine = word.trimStart();
74
+ currentWidth = currentLine.replace(/\x1b\[[0-9;]*m/g, "").length;
75
+ } else {
76
+ currentLine += word;
77
+ currentWidth += wordWidth;
78
+ }
79
+ }
80
+ if (currentLine) result.push(currentLine);
81
+ return result.length > 0 ? result : [""];
82
+ }
83
+
84
+ /** Apply inline formatting: bold, italic, code, links */
85
+ function formatInline(text: string): string {
86
+ // Inline code: `code`
87
+ text = text.replace(/`([^`]+)`/g, (_, code) => fmt(DIM, code));
88
+
89
+ // Bold: **text**
90
+ text = text.replace(/\*\*([^*]+)\*\*/g, (_, bold) => fmt(BOLD, bold));
91
+
92
+ // Italic: *text*
93
+ text = text.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, italic) => fmt(UNDERLINE, italic));
94
+
95
+ // Links: [text](url) → underlined text
96
+ text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, (_, linkText) => fmt(UNDERLINE, linkText));
97
+
98
+ return text;
99
+ }
100
+
101
+ /**
102
+ * Simple markdown renderer (fallback).
103
+ */
104
+ function renderSimple(text: string, width: number): string[] {
105
+ const lines = text.split("\n");
106
+ const result: string[] = [];
107
+ let inCodeBlock = false;
108
+
109
+ for (const line of lines) {
110
+ const trimmed = line.trim();
111
+
112
+ // Code fence toggle
113
+ if (trimmed.startsWith("```")) {
114
+ inCodeBlock = !inCodeBlock;
115
+ continue;
116
+ }
117
+
118
+ // Inside code block — dim, no formatting
119
+ if (inCodeBlock) {
120
+ const formatted = fmt(DIM, line);
121
+ result.push(...wordWrap(formatted, width));
122
+ continue;
123
+ }
124
+
125
+ // Heading: #, ##, ###
126
+ if (trimmed.startsWith("### ")) {
127
+ const heading = trimmed.slice(4);
128
+ const formatted = fmt(BOLD + UNDERLINE, heading);
129
+ result.push(...wordWrap(formatted, width));
130
+ continue;
131
+ }
132
+ if (trimmed.startsWith("## ")) {
133
+ const heading = trimmed.slice(3);
134
+ const formatted = fmt(BOLD, heading);
135
+ result.push(...wordWrap(formatted, width));
136
+ continue;
137
+ }
138
+ if (trimmed.startsWith("# ")) {
139
+ const heading = trimmed.slice(2);
140
+ const formatted = fmt(BOLD, heading);
141
+ result.push(...wordWrap(formatted, width));
142
+ continue;
143
+ }
144
+
145
+ // Bullet list: - or *
146
+ if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
147
+ const content = trimmed.slice(2);
148
+ const formatted = " • " + formatInline(content);
149
+ result.push(...wordWrap(formatted, width));
150
+ continue;
151
+ }
152
+
153
+ // Numbered list: 1. 2. etc.
154
+ const numberMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
155
+ if (numberMatch) {
156
+ const formatted = ` ${numberMatch[1]}. ${formatInline(numberMatch[2])}`;
157
+ result.push(...wordWrap(formatted, width));
158
+ continue;
159
+ }
160
+
161
+ // Empty line — preserve spacing
162
+ if (!trimmed) {
163
+ result.push("");
164
+ continue;
165
+ }
166
+
167
+ // Regular paragraph — apply inline formatting
168
+ const formatted = formatInline(trimmed);
169
+ result.push(...wordWrap(formatted, width));
170
+ }
171
+
172
+ return result;
173
+ }
package/src/readme.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @pi-unipi/updater — Readme discovery
3
+ *
4
+ * Discovers package README.md paths from the unipi monorepo.
5
+ * Root README from the unipi package root, package READMEs from
6
+ * node_modules/@pi-unipi/{name}/README.md.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from "fs";
10
+ import { join, resolve } from "path";
11
+ import { MODULES } from "@pi-unipi/core";
12
+ import type { ReadmeEntry } from "../types.js";
13
+
14
+ /** Short name → full package name mapping from MODULES constant */
15
+ const PACKAGE_MAP: Record<string, string> = {
16
+ core: MODULES.CORE,
17
+ workflow: MODULES.WORKFLOW,
18
+ ralph: MODULES.RALPH,
19
+ memory: MODULES.MEMORY,
20
+ "info-screen": MODULES.INFO_SCREEN,
21
+ registry: MODULES.REGISTRY,
22
+ mcp: MODULES.MCP,
23
+ task: MODULES.TASK,
24
+ "web-api": MODULES.WEB_API,
25
+ impeccable: MODULES.IMPECCABLE,
26
+ settings: MODULES.SETTINGS,
27
+ utility: MODULES.UTILITY,
28
+ "ask-user": MODULES.ASK_USER,
29
+ compactor: MODULES.COMPACTOR,
30
+ notify: MODULES.NOTIFY,
31
+ btw: MODULES.BTW,
32
+ milestone: MODULES.MILESTONE,
33
+ kanboard: MODULES.KANBOARD,
34
+ footer: MODULES.FOOTER,
35
+ updater: MODULES.UPDATER,
36
+ };
37
+
38
+ /** Resolve the unipi monorepo root directory */
39
+ function resolveUnipiRoot(): string {
40
+ // Walk up from this file to find the monorepo root (has package.json with @pi-unipi/unipi)
41
+ let dir = new URL(".", import.meta.url).pathname;
42
+ // From src/updater/src/ → go up 4 levels to reach monorepo root
43
+ // Actually: packages/updater/src/readme.ts → ../../.. = monorepo root
44
+ dir = resolve(dir, "../../..");
45
+ return dir;
46
+ }
47
+
48
+ /** Get version from a package.json at the given directory */
49
+ function getPackageVersion(dir: string): string {
50
+ try {
51
+ const pkgPath = join(dir, "package.json");
52
+ if (existsSync(pkgPath)) {
53
+ const raw = readFileSync(pkgPath, "utf-8");
54
+ const pkg = JSON.parse(raw);
55
+ return pkg.version ?? "0.0.0";
56
+ }
57
+ } catch (_err) {
58
+ // Ignore parse errors
59
+ }
60
+ return "0.0.0";
61
+ }
62
+
63
+ /**
64
+ * Discover all available README.md files.
65
+ * Returns entries for the root README and all package READMEs that exist.
66
+ */
67
+ export function discoverReadmes(): ReadmeEntry[] {
68
+ const root = resolveUnipiRoot();
69
+ const entries: ReadmeEntry[] = [];
70
+
71
+ // Root README
72
+ const rootReadme = join(root, "README.md");
73
+ if (existsSync(rootReadme)) {
74
+ entries.push({
75
+ name: "unipi",
76
+ packageName: "@pi-unipi/unipi",
77
+ version: getPackageVersion(root),
78
+ path: rootReadme,
79
+ });
80
+ }
81
+
82
+ // Package READMEs — check both workspace layout and node_modules layout
83
+ for (const [shortName, fullName] of Object.entries(PACKAGE_MAP)) {
84
+ // Workspace layout: packages/{shortName}/README.md
85
+ const workspacePath = join(root, "packages", shortName, "README.md");
86
+ // node_modules layout: node_modules/{fullName}/README.md
87
+ const nodeModulesPath = join(root, "node_modules", fullName, "README.md");
88
+
89
+ let readmePath: string | null = null;
90
+ let versionDir: string | null = null;
91
+
92
+ if (existsSync(workspacePath)) {
93
+ readmePath = workspacePath;
94
+ versionDir = join(root, "packages", shortName);
95
+ } else if (existsSync(nodeModulesPath)) {
96
+ readmePath = nodeModulesPath;
97
+ versionDir = join(root, "node_modules", fullName);
98
+ }
99
+
100
+ if (readmePath && versionDir) {
101
+ entries.push({
102
+ name: shortName,
103
+ packageName: fullName,
104
+ version: getPackageVersion(versionDir),
105
+ path: readmePath,
106
+ });
107
+ }
108
+ }
109
+
110
+ return entries;
111
+ }
112
+
113
+ /**
114
+ * Resolve the README path for a specific package.
115
+ * If packageName is undefined or empty, returns the root README.
116
+ * Returns null if the README doesn't exist.
117
+ */
118
+ export function resolveReadmePath(packageName?: string): string | null {
119
+ const root = resolveUnipiRoot();
120
+
121
+ if (!packageName || packageName === "unipi") {
122
+ const rootReadme = join(root, "README.md");
123
+ return existsSync(rootReadme) ? rootReadme : null;
124
+ }
125
+
126
+ // Look up the short name in the package map
127
+ const fullName = PACKAGE_MAP[packageName];
128
+ if (!fullName) return null;
129
+
130
+ // Check workspace layout first
131
+ const workspacePath = join(root, "packages", packageName, "README.md");
132
+ if (existsSync(workspacePath)) return workspacePath;
133
+
134
+ // Then node_modules
135
+ const nodeModulesPath = join(root, "node_modules", fullName, "README.md");
136
+ if (existsSync(nodeModulesPath)) return nodeModulesPath;
137
+
138
+ return null;
139
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @pi-unipi/updater — Configuration management
3
+ *
4
+ * Loads, saves, and validates updater config from ~/.unipi/config/updater/config.json
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
8
+ import { dirname, join } from "path";
9
+ import { homedir } from "os";
10
+ import { UPDATER_DIRS } from "@pi-unipi/core";
11
+ import type { UpdaterConfig } from "../types.js";
12
+
13
+ /** Default configuration — 1 hour check interval, notify mode */
14
+ export const DEFAULT_CONFIG: UpdaterConfig = {
15
+ checkIntervalMs: 3600000, // 1 hour
16
+ autoUpdate: "notify",
17
+ };
18
+
19
+ /** Valid check intervals in milliseconds */
20
+ const VALID_INTERVALS: Record<string, number> = {
21
+ "30min": 1800000,
22
+ "1h": 3600000,
23
+ "6h": 21600000,
24
+ "1d": 86400000,
25
+ };
26
+
27
+ /** Valid auto-update modes */
28
+ const VALID_MODES: UpdaterConfig["autoUpdate"][] = ["disabled", "notify", "auto"];
29
+
30
+ /** Resolve config path */
31
+ function resolveConfigPath(): string {
32
+ const base = UPDATER_DIRS.CONFIG.replace("~", homedir());
33
+ return join(base, "config.json");
34
+ }
35
+
36
+ /** Load config from disk, returning defaults if missing or invalid */
37
+ export function loadConfig(): UpdaterConfig {
38
+ const configPath = resolveConfigPath();
39
+ try {
40
+ if (existsSync(configPath)) {
41
+ const raw = readFileSync(configPath, "utf-8");
42
+ const parsed = JSON.parse(raw) as Partial<UpdaterConfig>;
43
+ return mergeWithDefaults(parsed);
44
+ }
45
+ } catch (_err) {
46
+ // Config load failure — using defaults silently.
47
+ }
48
+ return { ...DEFAULT_CONFIG };
49
+ }
50
+
51
+ /** Save config to disk, creating directory if needed */
52
+ export function saveConfig(config: UpdaterConfig): void {
53
+ const configPath = resolveConfigPath();
54
+ const dir = dirname(configPath);
55
+ mkdirSync(dir, { recursive: true });
56
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
57
+ }
58
+
59
+ /** Validate config, returning list of error messages */
60
+ export function validateConfig(config: UpdaterConfig): string[] {
61
+ const errors: string[] = [];
62
+
63
+ if (!VALID_MODES.includes(config.autoUpdate)) {
64
+ errors.push(`autoUpdate must be one of: ${VALID_MODES.join(", ")}`);
65
+ }
66
+
67
+ if (config.checkIntervalMs < 60000) {
68
+ errors.push("checkIntervalMs must be at least 60000 (1 minute)");
69
+ }
70
+
71
+ return errors;
72
+ }
73
+
74
+ /** Get human-readable label for an interval */
75
+ export function getIntervalLabel(ms: number): string {
76
+ for (const [label, value] of Object.entries(VALID_INTERVALS)) {
77
+ if (value === ms) return label;
78
+ }
79
+ return `${Math.round(ms / 60000)}min`;
80
+ }
81
+
82
+ /** Get all valid intervals as { label, ms } pairs */
83
+ export function getIntervalOptions(): Array<{ label: string; ms: number }> {
84
+ return Object.entries(VALID_INTERVALS).map(([label, ms]) => ({ label, ms }));
85
+ }
86
+
87
+ /** Get all valid auto-update modes */
88
+ export function getAutoUpdateOptions(): UpdaterConfig["autoUpdate"][] {
89
+ return [...VALID_MODES];
90
+ }
91
+
92
+ /** Merge loaded config with defaults to ensure all fields exist */
93
+ function mergeWithDefaults(loaded: Partial<UpdaterConfig>): UpdaterConfig {
94
+ return {
95
+ checkIntervalMs: loaded.checkIntervalMs ?? DEFAULT_CONFIG.checkIntervalMs,
96
+ autoUpdate: loaded.autoUpdate ?? DEFAULT_CONFIG.autoUpdate,
97
+ };
98
+ }