@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/README.md +71 -0
- package/index.ts +6 -0
- package/package.json +53 -0
- package/skills/configure-updater/SKILL.md +65 -0
- package/src/cache.ts +67 -0
- package/src/changelog.ts +141 -0
- package/src/checker.ts +84 -0
- package/src/commands.ts +83 -0
- package/src/index.ts +178 -0
- package/src/installer.ts +74 -0
- package/src/markdown.ts +173 -0
- package/src/readme.ts +139 -0
- package/src/settings.ts +98 -0
- package/src/tui/changelog-overlay.ts +256 -0
- package/src/tui/readme-overlay.ts +236 -0
- package/src/tui/settings-overlay.ts +191 -0
- package/src/tui/update-overlay.ts +261 -0
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
|
+
}
|
package/src/installer.ts
ADDED
|
@@ -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
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -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
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -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
|
+
}
|