@groeponline/pi-wishcraft 1.0.5 → 1.1.0
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/CHANGELOG.md +7 -0
- package/README.md +9 -2
- package/package.json +15 -9
- package/src/extension/skills/skill-manager.ts +52 -3
- package/src/extension/ui/custom-editor.ts +7 -4
- package/src/extension/ui/layout.ts +3 -2
- package/src/extension/ui/menu-views.ts +6 -1
- package/src/extension/ui/status-line-renderers.ts +4 -3
- package/src/segments/registry.ts +18 -6
- package/src/segments/system.ts +1 -1
- package/src/theme/colors.ts +14 -0
- package/src/welcome/renderer.ts +2 -2
- package/src/welcome/whats-new.ts +46 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.1.0] - 2026-08-23
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
- Skills manager refuses to delete skill files or directories that resolve (through symlinks) outside the canonical skill roots.
|
|
9
|
+
- Skills editor command validates `EDITOR` before use, falling back to `nvim` for unsafe values.
|
|
10
|
+
- Open-ports overlay reuses the shared `sshCommand` helper and fails explicitly on an invalid host instead of falling back to a local probe.
|
|
11
|
+
|
|
5
12
|
## [1.0.5] - 2026-08-23
|
|
6
13
|
|
|
7
14
|
## [1.0.4] - 2026-08-23
|
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
<p>
|
|
2
|
-
<img src="banner.png" alt="pi-wishcraft" width="1100">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/banner.png" alt="pi-wishcraft" width="1100">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<a href="https://www.npmjs.com/package/@groeponline/pi-wishcraft"><img src="https://img.shields.io/npm/v/@groeponline/pi-wishcraft.svg" alt="npm version"></a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/@groeponline/pi-wishcraft"><img src="https://img.shields.io/npm/dm/@groeponline/pi-wishcraft.svg?label=downloads" alt="npm downloads"></a>
|
|
8
|
+
<a href="https://pi.dev/packages/@groeponline/pi-wishcraft"><img src="https://img.shields.io/badge/Pi-package-9b59b6.svg" alt="Pi package"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-555.svg" alt="MIT license"></a>
|
|
3
10
|
</p>
|
|
4
11
|
|
|
5
12
|
# pi-wishcraft
|
|
@@ -26,7 +33,7 @@ Ephemeral VMs / CI:
|
|
|
26
33
|
curl -fsSL https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/scripts/install.sh | bash
|
|
27
34
|
```
|
|
28
35
|
|
|
29
|
-
Then restart pi or `/reload`.
|
|
36
|
+
Then restart pi or `/reload`. Pi host packages are declared as `peerDependencies: "*"`, matching the Pi package contract.
|
|
30
37
|
|
|
31
38
|
## What you get
|
|
32
39
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groeponline/pi-wishcraft",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"*.ts",
|
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
"powerline",
|
|
26
26
|
"status-bar",
|
|
27
27
|
"extension",
|
|
28
|
-
"groeponline"
|
|
28
|
+
"groeponline",
|
|
29
|
+
"terminal-ui",
|
|
30
|
+
"developer-tools",
|
|
31
|
+
"agent-ui",
|
|
32
|
+
"productivity",
|
|
33
|
+
"hooks"
|
|
29
34
|
],
|
|
30
35
|
"publishConfig": {
|
|
31
36
|
"access": "public",
|
|
@@ -45,14 +50,15 @@
|
|
|
45
50
|
"typecheck": "tsc --noEmit",
|
|
46
51
|
"test": "node --experimental-strip-types --test tests/**/*.test.ts",
|
|
47
52
|
"release": "node scripts/release.mjs",
|
|
48
|
-
"prepublishOnly": "tsc --noEmit && node scripts/verify-package.mjs",
|
|
49
|
-
"verify:package": "node scripts/verify-package.mjs",
|
|
50
|
-
"circular": "madge --circular src index.ts bash-mode queue"
|
|
53
|
+
"prepublishOnly": "tsc --noEmit && node scripts/verify-package.mjs && npm run verify:pi-package",
|
|
54
|
+
"verify:package": "node scripts/verify-package.mjs && node scripts/verify-pi-package-contract.mjs",
|
|
55
|
+
"circular": "madge --circular src index.ts bash-mode queue",
|
|
56
|
+
"verify:pi-package": "node scripts/verify-pi-package-contract.mjs"
|
|
51
57
|
},
|
|
52
58
|
"peerDependencies": {
|
|
53
|
-
"@earendil-works/pi-ai": "
|
|
54
|
-
"@earendil-works/pi-coding-agent": "
|
|
55
|
-
"@earendil-works/pi-tui": "
|
|
59
|
+
"@earendil-works/pi-ai": "*",
|
|
60
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
61
|
+
"@earendil-works/pi-tui": "*"
|
|
56
62
|
},
|
|
57
63
|
"devDependencies": {
|
|
58
64
|
"@earendil-works/pi-ai": "^0.84.0",
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
-
import { rmSync } from "node:fs";
|
|
16
|
+
import { realpathSync, rmSync } from "node:fs";
|
|
17
|
+
import { join, relative } from "node:path";
|
|
17
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { getAgentDir } from "../../paths/agent-dirs.ts";
|
|
18
20
|
import type { RuntimeState } from "../core/types.ts";
|
|
19
21
|
import {
|
|
20
22
|
applySkillFilter,
|
|
@@ -84,9 +86,49 @@ function shellQuote(value: string): string {
|
|
|
84
86
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Resolve `target` through symlinks and confirm it stays within one of the
|
|
91
|
+
* canonical skill roots (agent skills dir, `<cwd>/.pi/skills`, `<cwd>/skills`).
|
|
92
|
+
* Guards the recursive delete against catalog paths that a cloned/untrusted
|
|
93
|
+
* repo could point outside the expected trees. Returns false when the path
|
|
94
|
+
* cannot be resolved (e.g. already removed) so callers fail closed.
|
|
95
|
+
*/
|
|
96
|
+
export function isContainedInSkillRoots(target: string, cwd: string): boolean {
|
|
97
|
+
const roots = [
|
|
98
|
+
join(getAgentDir(), "skills"),
|
|
99
|
+
join(cwd, ".pi", "skills"),
|
|
100
|
+
join(cwd, "skills"),
|
|
101
|
+
];
|
|
102
|
+
let real: string;
|
|
103
|
+
try {
|
|
104
|
+
real = realpathSync(target);
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
return roots.some((root) => {
|
|
109
|
+
let realRoot: string;
|
|
110
|
+
try {
|
|
111
|
+
realRoot = realpathSync(root);
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const rel = relative(realRoot, real);
|
|
116
|
+
return rel !== "" && !rel.startsWith("..") && !rel.startsWith("/");
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Only allow a bare, path-like editor invocation (no arguments, no shell
|
|
122
|
+
* metacharacters) so a hostile `EDITOR` value cannot inject into the `!` flow.
|
|
123
|
+
* Falls back to `nvim` when the value is unusable.
|
|
124
|
+
*/
|
|
125
|
+
export function safeEditor(): string {
|
|
126
|
+
const ed = process.env.EDITOR?.trim();
|
|
127
|
+
return ed && /^[\w./-]+$/.test(ed) ? ed : "nvim";
|
|
128
|
+
}
|
|
129
|
+
|
|
87
130
|
function editorCommand(path: string): string {
|
|
88
|
-
|
|
89
|
-
return `!${ed} ${shellQuote(path)}`;
|
|
131
|
+
return `!${safeEditor()} ${shellQuote(path)}`;
|
|
90
132
|
}
|
|
91
133
|
|
|
92
134
|
export async function showSkillManager(ctx: any): Promise<"new" | null> {
|
|
@@ -137,11 +179,18 @@ export async function showSkillManager(ctx: any): Promise<"new" | null> {
|
|
|
137
179
|
const close = () => done(null);
|
|
138
180
|
|
|
139
181
|
const doDelete = (entry: SkillEntry) => {
|
|
182
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
140
183
|
try {
|
|
141
184
|
if (entry.isDirectorySkill && entry.filePath.endsWith("SKILL.md") &&
|
|
142
185
|
(entry.category === "global" || entry.category === "project")) {
|
|
186
|
+
if (!isContainedInSkillRoots(entry.baseDir, cwd)) {
|
|
187
|
+
throw new Error(`refusing to delete outside skill roots: ${entry.baseDir}`);
|
|
188
|
+
}
|
|
143
189
|
rmSync(entry.baseDir, { recursive: true, force: true });
|
|
144
190
|
} else {
|
|
191
|
+
if (!isContainedInSkillRoots(entry.filePath, cwd)) {
|
|
192
|
+
throw new Error(`refusing to delete outside skill roots: ${entry.filePath}`);
|
|
193
|
+
}
|
|
145
194
|
rmSync(entry.filePath, { force: true });
|
|
146
195
|
}
|
|
147
196
|
ctx.ui.notify(`Skill deleted: ${entry.name}`, "info");
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
passAutocompleteProviderThroughPreviousEditor,
|
|
22
22
|
} from "../../editor/autocomplete-chain.ts";
|
|
23
23
|
import { subscribeGitUpdates } from "../../git/status.ts";
|
|
24
|
-
import { ansi, getFgAnsiCode } from "../../theme/colors.ts";
|
|
24
|
+
import { ansi, colorEnabled, getFgAnsiCode } from "../../theme/colors.ts";
|
|
25
25
|
import {
|
|
26
26
|
restorePromptHistory,
|
|
27
27
|
snapshotPromptHistory,
|
|
@@ -320,7 +320,8 @@ export function setupCustomEditor(
|
|
|
320
320
|
return originalRender(width);
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
const bc = (s: string) =>
|
|
323
|
+
const bc = (s: string) =>
|
|
324
|
+
`${getFgAnsiCode("sep")}${s}${colorEnabled() ? ansi.reset : ""}`;
|
|
324
325
|
const captureDraft =
|
|
325
326
|
!rt.bashModeActive && isSigilIdeaDraft(editor.getExpandedText());
|
|
326
327
|
const promptGlyph = rt.bashModeActive
|
|
@@ -330,8 +331,10 @@ export function setupCustomEditor(
|
|
|
330
331
|
: ">";
|
|
331
332
|
const promptColor = captureDraft
|
|
332
333
|
? getFgAnsiCode("queue")
|
|
333
|
-
:
|
|
334
|
-
|
|
334
|
+
: colorEnabled()
|
|
335
|
+
? ansi.getFgAnsi(200, 200, 200)
|
|
336
|
+
: "";
|
|
337
|
+
const prompt = `${promptColor}${promptGlyph}${colorEnabled() ? ansi.reset : ""}`;
|
|
335
338
|
const promptPrefix = ` ${prompt} `;
|
|
336
339
|
const contPrefix = " ";
|
|
337
340
|
const contentWidth = Math.max(1, width - 3);
|
|
@@ -9,7 +9,7 @@ import { getPreset } from "../../config/presets.ts";
|
|
|
9
9
|
import { mergeSegmentsWithCustomItems } from "../../config/powerline-config.ts";
|
|
10
10
|
import { getSeparator } from "../../theme/separators.ts";
|
|
11
11
|
import { renderSegment } from "../../segments/index.ts";
|
|
12
|
-
import { ansi, getFgAnsiCode } from "../../theme/colors.ts";
|
|
12
|
+
import { ansi, colorEnabled, getFgAnsiCode } from "../../theme/colors.ts";
|
|
13
13
|
import { config } from "../core/state.ts";
|
|
14
14
|
|
|
15
15
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -41,7 +41,8 @@ export function buildContentFromParts(
|
|
|
41
41
|
const separatorDef = getSeparator(separatorStyle);
|
|
42
42
|
const sepAnsi = getFgAnsiCode("sep");
|
|
43
43
|
const sep = separatorDef.left;
|
|
44
|
-
|
|
44
|
+
const reset = colorEnabled() ? ansi.reset : "";
|
|
45
|
+
return " " + parts.join(` ${sepAnsi}${sep}${reset} `) + reset + " ";
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
countListeningPorts,
|
|
19
19
|
listOpenPortProcesses,
|
|
20
20
|
sanitizeSshHost,
|
|
21
|
+
sshCommand,
|
|
21
22
|
} from "../../segments/system.ts";
|
|
22
23
|
import {
|
|
23
24
|
writePowerlineCustomPresetSetting,
|
|
@@ -63,8 +64,12 @@ export async function showOpenPortsList(ctx: any): Promise<void> {
|
|
|
63
64
|
}
|
|
64
65
|
const proto = includeUdp ? "-tulnp" : "-tlnp";
|
|
65
66
|
const command = host
|
|
66
|
-
?
|
|
67
|
+
? sshCommand(host, `ss ${proto} 2>/dev/null`)
|
|
67
68
|
: `ss ${proto} 2>/dev/null`;
|
|
69
|
+
if (!command) {
|
|
70
|
+
ctx.ui.notify(`Invalid open-ports host: ${host}`, "error");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
68
73
|
const stdout = execSync(command, { encoding: "utf8" });
|
|
69
74
|
publishPowerlineStatuses(ctx, {
|
|
70
75
|
ports: formatPortsStatusValue(countListeningPorts(includeUdp, host)),
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
collectHiddenExtensionStatusKeys,
|
|
8
8
|
getNotificationExtensionStatuses,
|
|
9
9
|
} from "../../config/powerline-config.ts";
|
|
10
|
-
import { ansi, getFgAnsiCode } from "../../theme/colors.ts";
|
|
10
|
+
import { ansi, colorEnabled, getFgAnsiCode } from "../../theme/colors.ts";
|
|
11
11
|
|
|
12
12
|
import { computeResponsiveLayout } from "./layout.ts";
|
|
13
13
|
import { getQueueContext } from "../queue/queue-context.ts";
|
|
@@ -210,7 +210,8 @@ export function renderLastPromptLines(
|
|
|
210
210
|
): string[] {
|
|
211
211
|
if (rt.bashModeActive || !rt.showLastPrompt || !rt.lastUserPrompt) return [];
|
|
212
212
|
|
|
213
|
-
const
|
|
213
|
+
const reset = colorEnabled() ? ansi.reset : "";
|
|
214
|
+
const prefix = ` ${getFgAnsiCode("sep")}↳${reset} `;
|
|
214
215
|
const availableWidth = width - visibleWidth(prefix);
|
|
215
216
|
if (availableWidth < 10) return [];
|
|
216
217
|
|
|
@@ -219,7 +220,7 @@ export function renderLastPromptLines(
|
|
|
219
220
|
|
|
220
221
|
promptText = truncateToWidth(promptText, availableWidth, "…");
|
|
221
222
|
|
|
222
|
-
const styledPrompt = `${getFgAnsiCode("sep")}${promptText}${
|
|
223
|
+
const styledPrompt = `${getFgAnsiCode("sep")}${promptText}${reset}`;
|
|
223
224
|
const line = `${prefix}${styledPrompt}`;
|
|
224
225
|
return [truncateToWidth(line, width, "…")];
|
|
225
226
|
}
|
package/src/segments/registry.ts
CHANGED
|
@@ -135,6 +135,9 @@ export function applySegmentDecoration(
|
|
|
135
135
|
return { content, visible: true };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
const SEGMENT_ERROR_LOG_INTERVAL_MS = 10_000;
|
|
139
|
+
const lastSegmentErrorLog = new Map<StatusLineSegmentId, number>();
|
|
140
|
+
|
|
138
141
|
export const SEGMENTS: Record<BuiltinStatusLineSegmentId, StatusLineSegment> = {
|
|
139
142
|
model: modelSegment,
|
|
140
143
|
shell_mode: shellModeSegment,
|
|
@@ -177,11 +180,20 @@ export function renderSegment(
|
|
|
177
180
|
rendered = segment ? segment.render(ctx) : { content: "", visible: false };
|
|
178
181
|
}
|
|
179
182
|
return applySegmentDecoration(id, ctx, rendered);
|
|
180
|
-
} catch {
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// Per-segment fault isolation: keep the footer alive and identify the
|
|
185
|
+
// segment that failed. Log at most once every 10 seconds per segment.
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const lastLogged = lastSegmentErrorLog.get(id) ?? 0;
|
|
188
|
+
if (now - lastLogged >= SEGMENT_ERROR_LOG_INTERVAL_MS) {
|
|
189
|
+
lastSegmentErrorLog.set(id, now);
|
|
190
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
191
|
+
console.warn(`[wishcraft] segment "${id}" failed: ${detail}`);
|
|
192
|
+
}
|
|
193
|
+
return applySegmentDecoration(id, ctx, {
|
|
194
|
+
content: `!${id}`,
|
|
195
|
+
visible: true,
|
|
196
|
+
});
|
|
197
|
+
|
|
186
198
|
}
|
|
187
199
|
}
|
package/src/segments/system.ts
CHANGED
|
@@ -137,7 +137,7 @@ export function sanitizeSshHost(value: string | undefined): string | null {
|
|
|
137
137
|
* inner `2>/dev/null`; `BatchMode` fails fast (no password prompt) on hosts
|
|
138
138
|
* that require interactive auth or an unknown host key.
|
|
139
139
|
*/
|
|
140
|
-
function sshCommand(
|
|
140
|
+
export function sshCommand(
|
|
141
141
|
host: string | undefined,
|
|
142
142
|
remoteCmd: string,
|
|
143
143
|
): string | null {
|
package/src/theme/colors.ts
CHANGED
|
@@ -12,6 +12,18 @@ export const ansi: AnsiColors = {
|
|
|
12
12
|
reset: "\x1b[0m",
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// ponytail: NO_COLOR (de-facto standard — present and non-empty) disables all
|
|
16
|
+
// wishcraft color so the status bar stays plain text in no-color terminals
|
|
17
|
+
// and color-blind pipelines. Computed lazily so test env changes take effect.
|
|
18
|
+
let _colorEnabled: boolean | undefined;
|
|
19
|
+
export function colorEnabled(): boolean {
|
|
20
|
+
if (_colorEnabled === undefined) {
|
|
21
|
+
const v = process.env.NO_COLOR;
|
|
22
|
+
_colorEnabled = !(v != null && v !== "");
|
|
23
|
+
}
|
|
24
|
+
return _colorEnabled;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
function hexToRgb(hex: string): [number, number, number] {
|
|
16
28
|
const cleanHex = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
17
29
|
if (!/^[0-9A-Fa-f]{6}$/.test(cleanHex)) {
|
|
@@ -51,10 +63,12 @@ for (const key of Object.keys(PALETTE)) {
|
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
export function fgOnly(color: ColorName, text: string): string {
|
|
66
|
+
if (!colorEnabled()) return text;
|
|
54
67
|
const code = ansiCodeCache.get(color as string) ?? "";
|
|
55
68
|
return code ? `${code}${text}` : text;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
export function getFgAnsiCode(color: ColorName): string {
|
|
72
|
+
if (!colorEnabled()) return "";
|
|
59
73
|
return ansiCodeCache.get(color as string) ?? "";
|
|
60
74
|
}
|
package/src/welcome/renderer.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import { ansi, fgOnly, getFgAnsiCode } from "../theme/colors.ts";
|
|
2
|
+
import { ansi, colorEnabled, fgOnly, getFgAnsiCode } from "../theme/colors.ts";
|
|
3
3
|
import { centerText, fitToWidth, getBoxLayout } from "./layout.ts";
|
|
4
4
|
import type { WelcomeData, WelcomeWidget, WidgetRenderContext } from "./types.ts";
|
|
5
5
|
|
|
@@ -32,7 +32,7 @@ function bold(text: string): string {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function dim(text: string): string {
|
|
35
|
-
return getFgAnsiCode("sep") + text + ansi.reset;
|
|
35
|
+
return getFgAnsiCode("sep") + text + (colorEnabled() ? ansi.reset : "");
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function gradientLine(line: string): string {
|
package/src/welcome/whats-new.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { getAgentPath } from "../paths/agent-dirs.ts";
|
|
|
16
16
|
|
|
17
17
|
const MAX_WHATSNEW_LINES = 8;
|
|
18
18
|
const WHATS_NEW_STATE_FILE = "whats-new.json";
|
|
19
|
+
// First GroepOnline release. On a first run (no stored version) the welcome
|
|
20
|
+
// panel shows history from here forward, never the pre-1.0 `0.x` prehistory.
|
|
21
|
+
const FIRST_OWN_RELEASE = "1.0.0";
|
|
19
22
|
|
|
20
23
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
21
24
|
|
|
@@ -37,38 +40,71 @@ function isVersionNewer(version: string, lastSeen: string | null): boolean {
|
|
|
37
40
|
return false;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
function isVersionAtOrNewer(version: string, floor: string): boolean {
|
|
44
|
+
const next = parseVersion(version);
|
|
45
|
+
const min = parseVersion(floor);
|
|
46
|
+
if (!next || !min) return false;
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
if (next[i] > min[i]) return true;
|
|
49
|
+
if (next[i] < min[i]) return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
/**
|
|
41
55
|
* Extract the bullet lines from released changelog sections whose version is
|
|
42
|
-
* strictly newer than `lastSeenVersion
|
|
43
|
-
*
|
|
44
|
-
*
|
|
56
|
+
* strictly newer than `lastSeenVersion`. On a first run (`lastSeenVersion` is
|
|
57
|
+
* null) `floorVersion` becomes the lower bound, so the panel shows history from
|
|
58
|
+
* the first own release forward and skips the pre-1.0 prehistory. Sections are
|
|
59
|
+
* returned oldest-first (chronological "since your last version") and capped at
|
|
60
|
+
* `maxLines`. Markdown bold markers are stripped; backticks are kept so commands
|
|
61
|
+
* like `/powerline doctor` stay readable in the terminal.
|
|
45
62
|
*/
|
|
46
63
|
export function parseChangelogDelta(
|
|
47
64
|
changelog: string,
|
|
48
65
|
lastSeenVersion: string | null,
|
|
49
66
|
maxLines: number = MAX_WHATSNEW_LINES,
|
|
67
|
+
floorVersion: string | null = FIRST_OWN_RELEASE,
|
|
50
68
|
): string[] {
|
|
51
|
-
|
|
52
|
-
|
|
69
|
+
// The changelog is newest-first; collect per section, then reverse to read
|
|
70
|
+
// oldest-first before capping.
|
|
71
|
+
const sections: string[][] = [];
|
|
72
|
+
let current: string[] | null = null;
|
|
73
|
+
// A stored last-seen version is a strict lower bound (only strictly newer
|
|
74
|
+
// sections). On a first run the floor is inclusive, so the first own release
|
|
75
|
+
// itself is shown while the pre-1.0 prehistory is skipped.
|
|
76
|
+
const matchesBound =
|
|
77
|
+
lastSeenVersion !== null
|
|
78
|
+
? (version: string) => isVersionNewer(version, lastSeenVersion)
|
|
79
|
+
: floorVersion !== null
|
|
80
|
+
? (version: string) => isVersionAtOrNewer(version, floorVersion)
|
|
81
|
+
: () => true;
|
|
53
82
|
|
|
54
83
|
for (const rawLine of changelog.split("\n")) {
|
|
55
84
|
const line = rawLine.trimEnd();
|
|
56
85
|
const versionMatch = line.match(/^##\s+\[([^\]]+)\]/);
|
|
57
86
|
if (versionMatch) {
|
|
58
87
|
const version = versionMatch[1].trim();
|
|
59
|
-
|
|
60
|
-
|
|
88
|
+
const include = version !== "Unreleased" && matchesBound(version);
|
|
89
|
+
current = include ? [] : null;
|
|
90
|
+
if (current) sections.push(current);
|
|
61
91
|
continue;
|
|
62
92
|
}
|
|
63
|
-
if (!
|
|
93
|
+
if (!current) continue;
|
|
64
94
|
if (!line.startsWith("- ")) continue;
|
|
65
95
|
|
|
66
96
|
const cleaned = line.slice(2).trim().replace(/\*\*/g, "").trim();
|
|
67
97
|
if (!cleaned) continue;
|
|
68
|
-
|
|
69
|
-
if (entries.length >= maxLines) break;
|
|
98
|
+
current.push(cleaned);
|
|
70
99
|
}
|
|
71
100
|
|
|
101
|
+
const entries: string[] = [];
|
|
102
|
+
for (const section of sections.reverse()) {
|
|
103
|
+
for (const entry of section) {
|
|
104
|
+
entries.push(entry);
|
|
105
|
+
if (entries.length >= maxLines) return entries;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
72
108
|
return entries;
|
|
73
109
|
}
|
|
74
110
|
|