@pi-archimedes/ui 2.9.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/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +44 -0
- package/src/bash/index.ts +10 -0
- package/src/bash/renderer.test.ts +424 -0
- package/src/bash/renderer.ts +301 -0
- package/src/bash/tool.test.ts +26 -0
- package/src/bash/tool.ts +17 -0
- package/src/codemode/index.ts +20 -0
- package/src/codemode/loader.test.ts +139 -0
- package/src/codemode/loader.ts +115 -0
- package/src/codemode/renderer.test.ts +717 -0
- package/src/codemode/renderer.ts +399 -0
- package/src/codemode/tool.test.ts +149 -0
- package/src/codemode/tool.ts +96 -0
- package/src/config.test.ts +199 -0
- package/src/config.ts +129 -0
- package/src/editor/index.test.ts +820 -0
- package/src/editor/index.ts +380 -0
- package/src/editor/spin-quips.test.ts +106 -0
- package/src/editor/spin-quips.ts +44 -0
- package/src/editor/spin.test.ts +763 -0
- package/src/editor/spin.ts +493 -0
- package/src/index.test.ts +402 -0
- package/src/index.ts +235 -0
- package/src/migration.test.ts +256 -0
- package/src/migration.ts +94 -0
- package/src/settings.test.ts +122 -0
- package/src/settings.ts +135 -0
- package/src/startup/capture.ts +47 -0
- package/src/startup/index.test.ts +175 -0
- package/src/startup/index.ts +348 -0
- package/src/startup/logo.test.ts +260 -0
- package/src/startup/logo.ts +85 -0
- package/src/startup/sections.test.ts +333 -0
- package/src/startup/sections.ts +306 -0
- package/src/startup/version.test.ts +98 -0
- package/src/startup/version.ts +26 -0
- package/src/thinking/patch.test.ts +857 -0
- package/src/thinking/patch.ts +407 -0
- package/src/thinking/theme.test.ts +216 -0
- package/src/thinking/theme.ts +156 -0
- package/src/thinking/transform.test.ts +89 -0
- package/src/thinking/transform.ts +21 -0
- package/src/thinking/unindent.test.ts +74 -0
- package/src/thinking/unindent.ts +74 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { clampLine, stripAnsi } from "@pi-archimedes/core/text";
|
|
2
|
+
import { gray, rgb, extractRgb, lerp } from "@pi-archimedes/core/color";
|
|
3
|
+
import { TRUECOLOR } from "./logo.js";
|
|
4
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
// ── Types ──────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export const SECTION_KEYS = ["Models", "Context", "Prompts", "Skills", "Extensions", "Themes"] as const;
|
|
10
|
+
export type SectionKey = (typeof SECTION_KEYS)[number];
|
|
11
|
+
type RenderSectionKey = SectionKey | "Version";
|
|
12
|
+
|
|
13
|
+
export interface ParsedSection {
|
|
14
|
+
name: SectionKey;
|
|
15
|
+
items: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RenderSection {
|
|
19
|
+
name: RenderSectionKey;
|
|
20
|
+
items: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Animation constants ────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export const RAMP_FRAMES = 22;
|
|
26
|
+
export const STAGGER_FRAMES = 0;
|
|
27
|
+
export const BASE_FADE_DELAY = 3;
|
|
28
|
+
export const MAX_STAGGER = BASE_FADE_DELAY + 5 * STAGGER_FRAMES;
|
|
29
|
+
|
|
30
|
+
// ── Section detection & parsing ────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export function detectSection(plain: string): SectionKey | undefined {
|
|
33
|
+
for (const key of SECTION_KEYS) {
|
|
34
|
+
if (plain.includes(`[${key}]`)) return key;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseSectionText(plain: string): ParsedSection | undefined {
|
|
40
|
+
const sectionName = detectSection(plain);
|
|
41
|
+
if (!sectionName) return undefined;
|
|
42
|
+
|
|
43
|
+
const names = extractItemsFromSection(plain, sectionName);
|
|
44
|
+
const items = deduplicateItems(names);
|
|
45
|
+
return { name: sectionName, items };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract item names from a section's plain text.
|
|
50
|
+
* Tracks source prefixes (npm:/git:) for Extensions/Skills sections.
|
|
51
|
+
*/
|
|
52
|
+
function extractItemsFromSection(plain: string, sectionName: SectionKey): string[] {
|
|
53
|
+
const names: string[] = [];
|
|
54
|
+
const lines = plain.split("\n");
|
|
55
|
+
let currentSource = "";
|
|
56
|
+
let sourceIndent = 0;
|
|
57
|
+
const showSource = sectionName === "Extensions" || sectionName === "Skills";
|
|
58
|
+
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (shouldSkipLine(trimmed, currentSource)) continue;
|
|
62
|
+
|
|
63
|
+
const indent = line.length - line.trimStart().length;
|
|
64
|
+
|
|
65
|
+
// Track package source headers (e.g. "git:github.com/...", "npm:@foo/bar")
|
|
66
|
+
if (/^(git:|npm:)\S+\//.test(trimmed)) {
|
|
67
|
+
currentSource = trimmed.startsWith("git:") ? "git:" : "npm:";
|
|
68
|
+
sourceIndent = indent;
|
|
69
|
+
const name = extractName(trimmed, sectionName);
|
|
70
|
+
if (name && showSource) names.push(name);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Reset source prefix when indent returns to source level or shallower
|
|
75
|
+
if (currentSource && indent <= sourceIndent) {
|
|
76
|
+
currentSource = "";
|
|
77
|
+
sourceIndent = 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const itemNames = extractNames(trimmed, sectionName);
|
|
81
|
+
for (const n of itemNames) {
|
|
82
|
+
names.push(showSource && currentSource ? currentSource + n : n);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return names;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract item name(s) from a line.
|
|
90
|
+
*
|
|
91
|
+
* Collapsed section bodies are a single comma-joined line ("a, b, c") —
|
|
92
|
+
* split it so each name is its own item and the listing can wrap, instead
|
|
93
|
+
* of one clamped line ending in "...". Expanded bodies are per-item path
|
|
94
|
+
* lines; names never contain ", ", so splitting is safe.
|
|
95
|
+
*/
|
|
96
|
+
function extractNames(line: string, sectionName: SectionKey): string[] {
|
|
97
|
+
const parts = line.includes(", ")
|
|
98
|
+
? line.split(", ").map(p => p.trim()).filter(Boolean)
|
|
99
|
+
: [line];
|
|
100
|
+
const out: string[] = [];
|
|
101
|
+
for (const part of parts) {
|
|
102
|
+
const name = extractName(part, sectionName);
|
|
103
|
+
if (name) out.push(name);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Check if a line should be skipped during section parsing. */
|
|
109
|
+
function shouldSkipLine(trimmed: string, currentSource: string): boolean {
|
|
110
|
+
if (!trimmed) return true;
|
|
111
|
+
if (trimmed.startsWith("[")) return true;
|
|
112
|
+
if (/^(user|project|path)$/.test(trimmed)) return true;
|
|
113
|
+
if (/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/i.test(trimmed)) return true;
|
|
114
|
+
// Skip resolved file paths only under source headers
|
|
115
|
+
if (currentSource) {
|
|
116
|
+
if (/\/(src|dist|out|build|lib|bin)\//.test(trimmed)) return true;
|
|
117
|
+
if (/\.(ts|js)$/.test(trimmed) && trimmed.includes("/") && !/SKILL\.(ts|js)$/i.test(trimmed)) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Deduplicate items by bare name (without prefix).
|
|
124
|
+
* Prefers prefixed versions (npm:/git:) over bare names.
|
|
125
|
+
*/
|
|
126
|
+
function deduplicateItems(names: string[]): string[] {
|
|
127
|
+
const seen = new Map<string, string>();
|
|
128
|
+
for (const n of names) {
|
|
129
|
+
if (/^(index|dist|src|out|lib|bin)$/i.test(n)) continue;
|
|
130
|
+
const bare = n.replace(/^(npm:|git:)/, "");
|
|
131
|
+
if (!seen.has(bare) || n.includes(":")) seen.set(bare, n);
|
|
132
|
+
}
|
|
133
|
+
return [...seen.values()];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseModelScope(plain: string): ParsedSection | undefined {
|
|
137
|
+
const m = plain.match(/Model scope:\s*(.+)/i);
|
|
138
|
+
if (!m) return undefined;
|
|
139
|
+
const raw = m[1]!.replace(/\s*\(Ctrl\+\w[\w\s]*\)/gi, "");
|
|
140
|
+
const items = raw.split(",").map(s => s.trim()).filter(Boolean);
|
|
141
|
+
return items.length > 0 ? { name: "Models", items } : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Name extraction helpers ────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function detectOrigin(path: string): { prefix: string; clean: string } {
|
|
147
|
+
if (/^npm:/.test(path)) return { prefix: "npm:", clean: path.slice(4) };
|
|
148
|
+
if (/^git:/.test(path)) return { prefix: "git:", clean: path.slice(4) };
|
|
149
|
+
if (/^https?:\/\//.test(path)) return { prefix: "git:", clean: path };
|
|
150
|
+
return { prefix: "", clean: path };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cleanName(name: string): string {
|
|
154
|
+
return name.replace(/\.(ts|js|json|md|git)$/i, "");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type Extractor = (clean: string, prefix: string) => string;
|
|
158
|
+
|
|
159
|
+
const defaultExtract: Extractor = (clean, prefix) =>
|
|
160
|
+
prefix + cleanName(clean.split("/").pop() ?? clean);
|
|
161
|
+
|
|
162
|
+
const sectionExtractors: Record<SectionKey, Extractor> = {
|
|
163
|
+
Models: defaultExtract,
|
|
164
|
+
Themes: defaultExtract,
|
|
165
|
+
Prompts: (clean) => {
|
|
166
|
+
const base = clean.split("/").pop() ?? clean;
|
|
167
|
+
return cleanName(base) || clean;
|
|
168
|
+
},
|
|
169
|
+
Context: (clean) => clean.split("/").pop() ?? clean,
|
|
170
|
+
Skills: (clean, prefix) => {
|
|
171
|
+
if (!clean.includes("/")) return defaultExtract(clean, prefix);
|
|
172
|
+
const parts = clean.split("/");
|
|
173
|
+
const file = parts.pop() ?? "";
|
|
174
|
+
if (/^SKILL\.(md|ts|js)$/i.test(file)) {
|
|
175
|
+
return prefix + cleanName(parts.pop() ?? file);
|
|
176
|
+
}
|
|
177
|
+
return prefix + cleanName(file);
|
|
178
|
+
},
|
|
179
|
+
Extensions: (clean, prefix) => {
|
|
180
|
+
const stripped = clean.replace(/^https?:\/\/[^/]+\//, "");
|
|
181
|
+
const parts = stripped.split("/").filter(p => {
|
|
182
|
+
const lower = p.toLowerCase();
|
|
183
|
+
return p && !/^(index\.(ts|js)|src|dist|out|build|lib|bin)$/.test(lower);
|
|
184
|
+
});
|
|
185
|
+
if (parts.length > 0) return prefix + cleanName(parts.pop()!);
|
|
186
|
+
return prefix + cleanName(clean);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export function extractName(path: string, section: SectionKey): string {
|
|
191
|
+
const trimmed = path.trim();
|
|
192
|
+
const { prefix, clean } = detectOrigin(trimmed);
|
|
193
|
+
return sectionExtractors[section](clean, prefix);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Column formatter ───────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function pad(s: string, w: number): string {
|
|
199
|
+
const vw = visibleWidth(s);
|
|
200
|
+
return vw >= w ? s : s + " ".repeat(w - vw);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function formatColumns(sections: RenderSection[], theme: Theme, maxW: number, ref: { frame: number; revealed: boolean; revealedAt: number; scaffoldAt: number; settled: boolean }): string[] {
|
|
204
|
+
if (sections.length === 0) return [];
|
|
205
|
+
|
|
206
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
207
|
+
const muted = (t: string) => theme.fg("muted", t);
|
|
208
|
+
|
|
209
|
+
const headerW = Math.max(...sections.map(s => s.name.length + 2)) + 2;
|
|
210
|
+
|
|
211
|
+
const itemAge = ref.revealed ? ref.frame - ref.revealedAt : 0;
|
|
212
|
+
const labelAge = ref.revealed ? ref.frame - ref.scaffoldAt : 0;
|
|
213
|
+
|
|
214
|
+
// RGB endpoints for fade ramps (truecolor only)
|
|
215
|
+
const fadeStartRgb: [number, number, number] = [20, 20, 20];
|
|
216
|
+
let dimRgb: [number, number, number] | undefined;
|
|
217
|
+
let mutedRgb: [number, number, number] | undefined;
|
|
218
|
+
const labelRamping = TRUECOLOR && ref.revealed && labelAge < RAMP_FRAMES + MAX_STAGGER;
|
|
219
|
+
const itemRamping = TRUECOLOR && ref.revealed && itemAge < RAMP_FRAMES + MAX_STAGGER;
|
|
220
|
+
if (labelRamping || itemRamping) {
|
|
221
|
+
dimRgb = extractRgb(theme.fg("dim", " "));
|
|
222
|
+
mutedRgb = extractRgb(theme.fg("muted", " "));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const lines: string[] = [];
|
|
226
|
+
|
|
227
|
+
for (let si = 0; si < sections.length; si++) {
|
|
228
|
+
const sec = sections[si]!;
|
|
229
|
+
if (sec.items.length === 0) continue;
|
|
230
|
+
|
|
231
|
+
const availableW = maxW - headerW - 1;
|
|
232
|
+
|
|
233
|
+
// Label fade: near-invisible → dim (static dim when not revealed)
|
|
234
|
+
const secLabelAge = Math.max(0, labelAge - BASE_FADE_DELAY - si * STAGGER_FRAMES);
|
|
235
|
+
const wrapLabel = ref.revealed
|
|
236
|
+
? buildItemWrapper(secLabelAge, true, fadeStartRgb, dimRgb, dim)
|
|
237
|
+
: dim;
|
|
238
|
+
const header = wrapLabel(`[${sec.name}]`);
|
|
239
|
+
const paddedHeader = header + " ".repeat(Math.max(0, headerW - sec.name.length - 2));
|
|
240
|
+
|
|
241
|
+
// Item fade: near-invisible → muted
|
|
242
|
+
const secItemAge = Math.max(0, itemAge - BASE_FADE_DELAY - si * STAGGER_FRAMES);
|
|
243
|
+
const wrapItems = buildItemWrapper(secItemAge, ref.revealed, fadeStartRgb, mutedRgb, muted);
|
|
244
|
+
|
|
245
|
+
// Style prefix (npm:/git:) dimmer than the name
|
|
246
|
+
const styleItem = (raw: string): string => {
|
|
247
|
+
const prefixMatch = raw.match(/^(npm:|git:)/);
|
|
248
|
+
if (prefixMatch) {
|
|
249
|
+
const pfx = prefixMatch[1]!;
|
|
250
|
+
const name = raw.slice(pfx.length);
|
|
251
|
+
return wrapLabel(pfx) + wrapItems(name);
|
|
252
|
+
}
|
|
253
|
+
return wrapItems(raw);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
let currentLine = "";
|
|
257
|
+
let currentStyled = "";
|
|
258
|
+
let firstLine = true;
|
|
259
|
+
|
|
260
|
+
for (const item of sec.items) {
|
|
261
|
+
const itemW = visibleWidth(item);
|
|
262
|
+
const currentW = visibleWidth(currentLine);
|
|
263
|
+
|
|
264
|
+
if (currentLine && currentW + 2 + itemW > availableW) {
|
|
265
|
+
const rawLine = firstLine ? `${paddedHeader} ${currentStyled}` : " ".repeat(headerW + 1) + currentStyled;
|
|
266
|
+
lines.push(clampLine(rawLine, maxW));
|
|
267
|
+
currentLine = item;
|
|
268
|
+
currentStyled = styleItem(item);
|
|
269
|
+
firstLine = false;
|
|
270
|
+
} else {
|
|
271
|
+
currentLine = currentLine ? currentLine + " " + item : item;
|
|
272
|
+
currentStyled = currentStyled ? currentStyled + " " + styleItem(item) : styleItem(item);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (currentLine) {
|
|
276
|
+
const rawLine = firstLine ? `${paddedHeader} ${currentStyled}` : " ".repeat(headerW + 1) + currentStyled;
|
|
277
|
+
lines.push(clampLine(rawLine, maxW));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (sec.name === "Version") {
|
|
281
|
+
lines.push("");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return lines;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function buildItemWrapper(
|
|
289
|
+
sectionAge: number,
|
|
290
|
+
revealed: boolean,
|
|
291
|
+
startRgb: [number, number, number] | undefined,
|
|
292
|
+
mutedRgb: [number, number, number] | undefined,
|
|
293
|
+
muted: (t: string) => string,
|
|
294
|
+
): (text: string) => string {
|
|
295
|
+
if (!revealed) return (text) => text; // placeholders already styled
|
|
296
|
+
|
|
297
|
+
// No truecolor or ramp done → static muted
|
|
298
|
+
if (!startRgb || !mutedRgb || sectionAge >= RAMP_FRAMES) return muted;
|
|
299
|
+
|
|
300
|
+
const t = Math.min(1, sectionAge / RAMP_FRAMES);
|
|
301
|
+
const eased = 1 - (1 - t) * (1 - t);
|
|
302
|
+
const r = lerp(startRgb[0], mutedRgb[0], eased);
|
|
303
|
+
const g = lerp(startRgb[1], mutedRgb[1], eased);
|
|
304
|
+
const b = lerp(startRgb[2], mutedRgb[2], eased);
|
|
305
|
+
return (text) => rgb(r, g, b, text);
|
|
306
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { compareVersions } from "./version.js";
|
|
4
|
+
|
|
5
|
+
// ── Version generator for property tests ─────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const versionArb = fc.tuple(fc.nat(100), fc.nat(100), fc.nat(100)).map(
|
|
8
|
+
([major, minor, patch]) => `${major}.${minor}.${patch}`,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
// ── compareVersions ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("compareVersions", () => {
|
|
14
|
+
it('returns 0 for equal versions', () => {
|
|
15
|
+
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns 1 when first version is greater', () => {
|
|
19
|
+
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns -1 when second version is greater', () => {
|
|
23
|
+
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("handles v prefix on first argument", () => {
|
|
27
|
+
expect(compareVersions("v1.2.3", "1.2.3")).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("handles v prefix on second argument", () => {
|
|
31
|
+
expect(compareVersions("1.2.3", "v1.2.3")).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("handles v prefix on both arguments", () => {
|
|
35
|
+
expect(compareVersions("v2.0.0", "v1.0.0")).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("handles partial versions (2 parts vs 3 parts)", () => {
|
|
39
|
+
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles partial versions with difference", () => {
|
|
43
|
+
expect(compareVersions("1.1", "1.0.5")).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("handles zero-padded versions", () => {
|
|
47
|
+
expect(compareVersions("01.02.03", "1.2.3")).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("compares minor version correctly", () => {
|
|
51
|
+
expect(compareVersions("1.2.0", "1.1.9")).toBe(1);
|
|
52
|
+
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("compares patch version correctly", () => {
|
|
56
|
+
expect(compareVersions("1.0.5", "1.0.3")).toBe(1);
|
|
57
|
+
expect(compareVersions("1.0.3", "1.0.5")).toBe(-1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("handles all-zero versions", () => {
|
|
61
|
+
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Property tests ───────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
it("property: reflexive — compareVersions(a, a) === 0", () => {
|
|
67
|
+
fc.assert(
|
|
68
|
+
fc.property(versionArb, a => {
|
|
69
|
+
return compareVersions(a, a) === 0;
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("property: antisymmetric — compareVersions(a, b) === -compareVersions(b, a)", () => {
|
|
75
|
+
fc.assert(
|
|
76
|
+
fc.property(versionArb, versionArb, (a, b) => {
|
|
77
|
+
return compareVersions(a, b) === -compareVersions(b, a);
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("property: transitivity — if a<b and b<c then a<c", () => {
|
|
83
|
+
fc.assert(
|
|
84
|
+
fc.property(
|
|
85
|
+
fc.tuple(versionArb, versionArb, versionArb),
|
|
86
|
+
([a, b, c]) => {
|
|
87
|
+
const ab = compareVersions(a, b);
|
|
88
|
+
const bc = compareVersions(b, c);
|
|
89
|
+
const ac = compareVersions(a, c);
|
|
90
|
+
if (ab < 0 && bc < 0) {
|
|
91
|
+
return ac < 0;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const NPM_REGISTRY_URL = "https://registry.npmjs.org/@earendil-works/pi-coding-agent/latest";
|
|
2
|
+
const FETCH_TIMEOUT_MS = 4000;
|
|
3
|
+
|
|
4
|
+
export async function fetchLatestVersion(): Promise<string | undefined> {
|
|
5
|
+
try {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
8
|
+
const res = await fetch(NPM_REGISTRY_URL, { signal: controller.signal });
|
|
9
|
+
clearTimeout(timeout);
|
|
10
|
+
if (!res.ok) return undefined;
|
|
11
|
+
const data = (await res.json()) as { version?: string };
|
|
12
|
+
return data.version;
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function compareVersions(a: string, b: string): number {
|
|
19
|
+
const pa = a.replace(/^v/, "").split(".").map(Number);
|
|
20
|
+
const pb = b.replace(/^v/, "").split(".").map(Number);
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
|
|
23
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|