@pugi/cli 0.1.0-beta.21 → 0.1.0-beta.23
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/dist/core/auth/env-provider.js +238 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/engine/native-pugi.js +55 -11
- package/dist/core/engine/prompts.js +30 -2
- package/dist/core/engine/tool-bridge.js +32 -0
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/permissions/index.js +1 -1
- package/dist/core/permissions/state.js +55 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/session.js +482 -12
- package/dist/core/repl/slash-commands.js +134 -1
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/runtime/cli.js +603 -15
- package/dist/runtime/commands/doctor.js +21 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/registry.js +8 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tui/compact-banner.js +28 -1
- package/dist/tui/conversation-pane.js +13 -0
- package/dist/tui/doctor-table.js +32 -17
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/repl-render.js +26 -3
- package/dist/tui/repl.js +9 -1
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/vim-input.js +267 -0
- package/package.json +2 -2
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leak L32 (2026-05-27) — `PUGI.md` hierarchy walk-up to `$HOME`.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code walks from `cwd` upward toward the user's homedir and
|
|
5
|
+
* concatenates every `CLAUDE.md` it finds at each intermediate level
|
|
6
|
+
* (deepest overrides shallowest). Pugi parity: same walk, looking for
|
|
7
|
+
* `PUGI.md` first at each level and accepting `CLAUDE.md` as a fallback
|
|
8
|
+
* — operators often have a leftover `~/CLAUDE.md` or a parent-dir
|
|
9
|
+
* `CLAUDE.md` from a previous Claude Code session and we want their
|
|
10
|
+
* ambient guidance picked up automatically without a migration step.
|
|
11
|
+
*
|
|
12
|
+
* Why this is a separate module from `core/context/markdown-traverse.ts`:
|
|
13
|
+
*
|
|
14
|
+
* - `markdown-traverse.ts` is the *workspace-bounded* walk (cwd → up
|
|
15
|
+
* to but NOT including `workspaceRoot`). It guards every read by
|
|
16
|
+
* `realpathSync` containment against the workspace root and
|
|
17
|
+
* refuses to escape — by design, because the per-dir markdown is
|
|
18
|
+
* part of the project's first-party context.
|
|
19
|
+
*
|
|
20
|
+
* - This module is the *home-bounded* walk (cwd → up to `homedir()`,
|
|
21
|
+
* OR until depth limit). It picks up the operator's personal /
|
|
22
|
+
* global guidance that lives ABOVE the workspace root. The two
|
|
23
|
+
* surfaces are complementary: workspace markdown encodes project
|
|
24
|
+
* conventions; this hierarchy walk encodes operator-level taste
|
|
25
|
+
* (preferred libraries, "always run prettier", style guides).
|
|
26
|
+
*
|
|
27
|
+
* Contract:
|
|
28
|
+
*
|
|
29
|
+
* - Walks from `cwd` upward. At each directory checks `PUGI.md`
|
|
30
|
+
* (preferred); when absent falls back to `CLAUDE.md`. Only ONE
|
|
31
|
+
* file per level is loaded — preferred wins.
|
|
32
|
+
* - Stops at `homedir()` INCLUSIVE — the file at `~/PUGI.md` or
|
|
33
|
+
* `~/CLAUDE.md` IS loaded (Claude Code parity: a `~/CLAUDE.md`
|
|
34
|
+
* applies to every project the operator opens).
|
|
35
|
+
* - Hard depth cap of `MAX_WALK_DEPTH` (20) directories regardless
|
|
36
|
+
* of how far cwd is from homedir; defense against symlinked or
|
|
37
|
+
* malicious cwd values.
|
|
38
|
+
* - Per-file byte cap `MAX_FILE_BYTES` (32 KB); over-cap files are
|
|
39
|
+
* truncated, not rejected, so a runaway `PUGI.md` does not break
|
|
40
|
+
* the prompt budget.
|
|
41
|
+
* - Returns shallow-to-deep order (cwd FIRST, homedir LAST). The
|
|
42
|
+
* caller is responsible for rendering precedence — Claude Code's
|
|
43
|
+
* rule is "deeper overrides shallower", which means the LAST
|
|
44
|
+
* entry in the rendered system prompt wins. Our order matches
|
|
45
|
+
* that convention so the context injector can splice directly.
|
|
46
|
+
*
|
|
47
|
+
* Safety:
|
|
48
|
+
*
|
|
49
|
+
* - No `realpath` on the directories themselves: the operator's
|
|
50
|
+
* cwd may live under a workspace symlink (common with macOS
|
|
51
|
+
* `/private/var/...`) and we want to honor what the operator
|
|
52
|
+
* sees in their shell. We DO resolve the candidate file via
|
|
53
|
+
* `realpathSync` before reading, but only to defeat
|
|
54
|
+
* symlinks-pointing-outside-homedir attacks; an off-tree symlink
|
|
55
|
+
* is skipped silently.
|
|
56
|
+
* - Catch + skip every fs error per file. The walk-up surface MUST
|
|
57
|
+
* NEVER break engine boot — missing read perms on a parent dir
|
|
58
|
+
* is the common case (e.g. `/etc` on a corp laptop) and the
|
|
59
|
+
* fallback is "no ambient context", not a crash.
|
|
60
|
+
*
|
|
61
|
+
* Pure module: no logging, no network, no fs writes.
|
|
62
|
+
*/
|
|
63
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
64
|
+
import { dirname, resolve } from 'node:path';
|
|
65
|
+
/**
|
|
66
|
+
* Hard ceiling on parent-dir traversal depth. 20 is generous — even
|
|
67
|
+
* deep monorepo layouts rarely sit more than 8-10 levels below the
|
|
68
|
+
* homedir on a developer's laptop. The cap exists so a misconfigured
|
|
69
|
+
* cwd (e.g. cwd outside the user's home filesystem entirely) cannot
|
|
70
|
+
* cause a multi-second fs scan of unrelated directories.
|
|
71
|
+
*/
|
|
72
|
+
export const MAX_WALK_DEPTH = 20;
|
|
73
|
+
/**
|
|
74
|
+
* Per-file byte cap. 32 KB matches the per-dir markdown traverse
|
|
75
|
+
* aggregate budget — generous enough for a fully written-out
|
|
76
|
+
* project / personal `PUGI.md` (~8000 words) while keeping any one
|
|
77
|
+
* file from blowing the prompt budget on its own.
|
|
78
|
+
*/
|
|
79
|
+
export const MAX_FILE_BYTES = 32 * 1024;
|
|
80
|
+
/**
|
|
81
|
+
* Filenames consulted at each level, in lookup order. `PUGI.md` is
|
|
82
|
+
* preferred — when both files coexist in a directory the Pugi-native
|
|
83
|
+
* file wins and the Claude Code shim is ignored. This is the same
|
|
84
|
+
* precedence used by `markdown-traverse.ts` for workspace-bounded
|
|
85
|
+
* walks; keeping the two surfaces consistent removes the "why does
|
|
86
|
+
* Pugi sometimes read CLAUDE.md and sometimes PUGI.md?" foot-gun.
|
|
87
|
+
*/
|
|
88
|
+
export const HIERARCHY_SOURCES = ['PUGI.md', 'CLAUDE.md'];
|
|
89
|
+
/**
|
|
90
|
+
* Walk from `cwd` upward, collecting ambient `PUGI.md` / `CLAUDE.md`
|
|
91
|
+
* files at each level until we reach the homedir (inclusive) or the
|
|
92
|
+
* depth cap.
|
|
93
|
+
*
|
|
94
|
+
* Returns an array ordered shallowest-first (cwd → homedir). When no
|
|
95
|
+
* files are found, returns `[]`. When `cwd` is OUTSIDE the homedir
|
|
96
|
+
* tree (e.g. the operator runs `pugi` from `/tmp`), the walk still
|
|
97
|
+
* proceeds upward but stops the moment we reach a filesystem root
|
|
98
|
+
* without ever entering the homedir — useful for ops/admin invocations
|
|
99
|
+
* where there is genuinely no personal context to load.
|
|
100
|
+
*/
|
|
101
|
+
export function walkUpPugiMd(cwd, opts = {}) {
|
|
102
|
+
const limit = clampLimit(opts.limit);
|
|
103
|
+
const home = opts.homedir;
|
|
104
|
+
let absCwd;
|
|
105
|
+
try {
|
|
106
|
+
absCwd = resolve(cwd);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const absHome = home ? resolve(home) : undefined;
|
|
112
|
+
const results = [];
|
|
113
|
+
let current = absCwd;
|
|
114
|
+
let level = 0;
|
|
115
|
+
const visited = new Set();
|
|
116
|
+
while (level <= limit) {
|
|
117
|
+
if (visited.has(current))
|
|
118
|
+
break; // pathological symlink loop guard
|
|
119
|
+
visited.add(current);
|
|
120
|
+
const found = tryLoadDirectory(current, level);
|
|
121
|
+
if (found)
|
|
122
|
+
results.push(found);
|
|
123
|
+
// Inclusive home boundary: load home if we are here, then stop.
|
|
124
|
+
if (absHome && current === absHome)
|
|
125
|
+
break;
|
|
126
|
+
const parent = dirname(current);
|
|
127
|
+
if (parent === current)
|
|
128
|
+
break; // hit filesystem root before homedir
|
|
129
|
+
current = parent;
|
|
130
|
+
level += 1;
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Pick the first matching file in `dir`, read + cap it, and produce
|
|
136
|
+
* a HierarchyFile row. Returns `undefined` when no file in
|
|
137
|
+
* `HIERARCHY_SOURCES` exists or all reads error out (perms, symlink
|
|
138
|
+
* escape, etc.). NEVER throws — fs errors degrade to "no file at
|
|
139
|
+
* this level".
|
|
140
|
+
*/
|
|
141
|
+
function tryLoadDirectory(dir, level) {
|
|
142
|
+
for (const source of HIERARCHY_SOURCES) {
|
|
143
|
+
const candidate = resolve(dir, source);
|
|
144
|
+
if (!existsSync(candidate))
|
|
145
|
+
continue;
|
|
146
|
+
// Realpath the FILE to defeat symlink-points-elsewhere attacks.
|
|
147
|
+
// We do not realpath the directory itself — operators often run
|
|
148
|
+
// pugi from inside a workspace symlink and the walk should honor
|
|
149
|
+
// the path they see.
|
|
150
|
+
let realPath;
|
|
151
|
+
try {
|
|
152
|
+
realPath = realpathSync(candidate);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Broken symlink or perms issue on the link itself. Skip this
|
|
156
|
+
// file and try the next source in the same directory.
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
let rawBytes;
|
|
160
|
+
try {
|
|
161
|
+
rawBytes = statSync(realPath).size;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let content;
|
|
167
|
+
try {
|
|
168
|
+
content = readFileSync(realPath, 'utf8');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
let truncated = false;
|
|
174
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
|
|
175
|
+
// Trim by character index sized to the byte cap. Mild over-trim
|
|
176
|
+
// on multi-byte boundaries is acceptable — we never under-trim
|
|
177
|
+
// (we'd exceed the cap) and the truncation is operator-visible
|
|
178
|
+
// via the `truncated` flag.
|
|
179
|
+
content = content.slice(0, MAX_FILE_BYTES);
|
|
180
|
+
truncated = true;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
path: realPath,
|
|
184
|
+
content,
|
|
185
|
+
level,
|
|
186
|
+
source,
|
|
187
|
+
truncated,
|
|
188
|
+
rawBytes,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Bound the limit to `[0, MAX_WALK_DEPTH]`. A negative or zero value
|
|
195
|
+
* still permits the cwd-level file to load (level 0 is always
|
|
196
|
+
* considered) — passing `limit: 0` means "current directory only".
|
|
197
|
+
*/
|
|
198
|
+
function clampLimit(limit) {
|
|
199
|
+
if (typeof limit !== 'number' || !Number.isFinite(limit))
|
|
200
|
+
return MAX_WALK_DEPTH;
|
|
201
|
+
if (limit < 0)
|
|
202
|
+
return 0;
|
|
203
|
+
if (limit > MAX_WALK_DEPTH)
|
|
204
|
+
return MAX_WALK_DEPTH;
|
|
205
|
+
return Math.floor(limit);
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=walk-up.js.map
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep-a-Changelog parser — Leak L24 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Parses a `CHANGELOG.md` written в the Keep-a-Changelog v1.1 layout
|
|
5
|
+
* (https://keepachangelog.com) into an ordered list of version
|
|
6
|
+
* sections. The parser is intentionally minimal — it understands the
|
|
7
|
+
* `## [<version>] - <date>` header marker and the section body up к
|
|
8
|
+
* the next header, and ignores every other Markdown construct (links,
|
|
9
|
+
* footnotes, sub-sub-headers). That is enough к drive the
|
|
10
|
+
* `pugi release-notes` diff between last-seen and current.
|
|
11
|
+
*
|
|
12
|
+
* # Module contract
|
|
13
|
+
*
|
|
14
|
+
* - Pure function. Takes the raw CHANGELOG text + returns parsed
|
|
15
|
+
* sections. Zero IO; the file read happens at the call site so the
|
|
16
|
+
* spec can pin fixtures without touching disk.
|
|
17
|
+
*
|
|
18
|
+
* - Header grammar: `## [<version>] - <YYYY-MM-DD>`. The leading
|
|
19
|
+
* `## ` is required (h2). The version is everything between the
|
|
20
|
+
* square brackets — semver-shaped strings are not validated
|
|
21
|
+
* beyond non-emptiness so pre-release tags like `0.1.0-beta.21`
|
|
22
|
+
* parse correctly. The date is captured verbatim; the comparator
|
|
23
|
+
* does not parse it — version ordering is enough.
|
|
24
|
+
*
|
|
25
|
+
* - Section body: every line from the header (exclusive) up к the
|
|
26
|
+
* next `## [...] - ...` header (exclusive). Lines are joined
|
|
27
|
+
* verbatim with `\n`; leading + trailing blank lines are trimmed
|
|
28
|
+
* so the renderer can paste sections back-to-back without double
|
|
29
|
+
* blank lines piling up.
|
|
30
|
+
*
|
|
31
|
+
* - Pre-header content (the leading `# Changelog` + introduction
|
|
32
|
+
* blurb) is discarded. The parser only surfaces version sections;
|
|
33
|
+
* callers that want к render the introduction read the source
|
|
34
|
+
* file directly.
|
|
35
|
+
*
|
|
36
|
+
* - Sections appear в the order they are written in the file. The
|
|
37
|
+
* Keep-a-Changelog convention is newest-first; the parser does
|
|
38
|
+
* NOT re-sort. The slicing helpers (`sliceVersionsBetween`) trust
|
|
39
|
+
* the input order so a malformed CHANGELOG with shuffled
|
|
40
|
+
* versions produces a deterministic — if surprising — diff
|
|
41
|
+
* instead of silently swallowing entries.
|
|
42
|
+
*
|
|
43
|
+
* - Semver comparison (`compareSemver`) handles the canonical
|
|
44
|
+
* `MAJOR.MINOR.PATCH[-PRERELEASE]` shape. The pre-release tail
|
|
45
|
+
* is compared lexicographically by dot-separated identifier per
|
|
46
|
+
* semver §11. Unknown / malformed input compares lower than any
|
|
47
|
+
* valid semver so an accidental `unknown` last-seen marker
|
|
48
|
+
* never blocks the operator from seeing new notes.
|
|
49
|
+
*/
|
|
50
|
+
const SECTION_HEADER_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/u;
|
|
51
|
+
/**
|
|
52
|
+
* Parse the raw `CHANGELOG.md` text into ordered version sections.
|
|
53
|
+
*
|
|
54
|
+
* Returns sections in the same order they appear in the source. The
|
|
55
|
+
* Keep-a-Changelog convention is newest-first; the parser does not
|
|
56
|
+
* re-sort.
|
|
57
|
+
*/
|
|
58
|
+
export function parseChangelog(raw) {
|
|
59
|
+
const lines = raw.split(/\r?\n/u);
|
|
60
|
+
const sections = [];
|
|
61
|
+
let current = null;
|
|
62
|
+
const flush = () => {
|
|
63
|
+
if (!current)
|
|
64
|
+
return;
|
|
65
|
+
sections.push({
|
|
66
|
+
version: current.version,
|
|
67
|
+
date: current.date,
|
|
68
|
+
body: trimBlankEdges(current.lines).join('\n'),
|
|
69
|
+
});
|
|
70
|
+
current = null;
|
|
71
|
+
};
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const match = SECTION_HEADER_RE.exec(line);
|
|
74
|
+
if (match) {
|
|
75
|
+
flush();
|
|
76
|
+
const version = (match[1] ?? '').trim();
|
|
77
|
+
const date = (match[2] ?? '').trim();
|
|
78
|
+
if (version.length === 0) {
|
|
79
|
+
// Malformed `## []` header — skip entirely instead of
|
|
80
|
+
// emitting a zero-version row that would corrupt the diff.
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
current = { version, date, lines: [] };
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (current) {
|
|
87
|
+
current.lines.push(line);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
flush();
|
|
91
|
+
return sections;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Slice the section list к those strictly newer than `lastSeen` and
|
|
95
|
+
* up к (and including) `current`. Both bounds are matched on the
|
|
96
|
+
* verbatim version string — the comparator runs on every section к
|
|
97
|
+
* decide membership.
|
|
98
|
+
*
|
|
99
|
+
* Semantics:
|
|
100
|
+
*
|
|
101
|
+
* - If `lastSeen` is null OR empty OR matches no section, every
|
|
102
|
+
* section ≤ current is returned. This is the first-run path —
|
|
103
|
+
* the operator has never run the command before, so the entire
|
|
104
|
+
* bundled changelog is fair game.
|
|
105
|
+
*
|
|
106
|
+
* - If `lastSeen` equals `current`, the empty array is returned.
|
|
107
|
+
* The caller renders the "no new release notes" copy.
|
|
108
|
+
*
|
|
109
|
+
* - If `lastSeen` is newer than `current`, the empty array is
|
|
110
|
+
* returned. This is the dev-build path — operators running a
|
|
111
|
+
* local build of `0.1.0-beta.30` against a registry that
|
|
112
|
+
* publishes `0.1.0-beta.22` would otherwise see the stale
|
|
113
|
+
* bundled notes; the caller still surfaces the same no-op copy.
|
|
114
|
+
*
|
|
115
|
+
* - Otherwise: sections strictly newer than `lastSeen` and ≤
|
|
116
|
+
* `current`, in the source order (newest-first by convention).
|
|
117
|
+
*
|
|
118
|
+
* The function is pure — no IO, no clock — so the spec can pin every
|
|
119
|
+
* branch with hand-rolled fixtures.
|
|
120
|
+
*/
|
|
121
|
+
export function sliceVersionsBetween(sections, lastSeen, current) {
|
|
122
|
+
if (sections.length === 0)
|
|
123
|
+
return [];
|
|
124
|
+
// Treat а blank / sentinel last-seen as "never seen".
|
|
125
|
+
const lastSeenValue = typeof lastSeen === 'string' && lastSeen.trim().length > 0 && lastSeen !== 'none'
|
|
126
|
+
? lastSeen.trim()
|
|
127
|
+
: null;
|
|
128
|
+
// Dev-build path: operator's last-seen marker is strictly newer than
|
|
129
|
+
// the installed CLI version. This happens when running а local build
|
|
130
|
+
// older than the registry, or when the operator manually edited the
|
|
131
|
+
// marker. Either way, return the empty array — re-rendering the
|
|
132
|
+
// bundled notes would be misleading. The renderer surfaces the same
|
|
133
|
+
// "no new release notes" copy as the up-to-date branch.
|
|
134
|
+
if (lastSeenValue !== null && compareSemver(lastSeenValue, current) > 0) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
// Diff path: surface sections strictly newer than the marker and ≤
|
|
138
|
+
// current. The comparator drives every section — а marker that does
|
|
139
|
+
// not appear in the bundled changelog (operator on а stale build,
|
|
140
|
+
// hand-edited marker, version no longer published) still bisects
|
|
141
|
+
// correctly because every comparison runs against the marker value
|
|
142
|
+
// directly, not the matching-section guard.
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const section of sections) {
|
|
145
|
+
// Anything newer than current is а future entry that should not
|
|
146
|
+
// surface until the operator actually upgrades — guards against а
|
|
147
|
+
// dev build of CHANGELOG.md leaking unreleased notes к а customer
|
|
148
|
+
// install.
|
|
149
|
+
if (compareSemver(section.version, current) > 0)
|
|
150
|
+
continue;
|
|
151
|
+
if (lastSeenValue !== null && compareSemver(section.version, lastSeenValue) <= 0) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
out.push(section);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Semver comparator. Returns a negative number when `a < b`, zero
|
|
160
|
+
* when equal, and a positive number when `a > b`. Pre-release tags
|
|
161
|
+
* compare lexicographically by dot-separated identifier per semver §11.
|
|
162
|
+
*
|
|
163
|
+
* Unknown / malformed input compares lower than any valid semver so
|
|
164
|
+
* an accidental sentinel like `unknown` or `none` never accidentally
|
|
165
|
+
* blocks the diff from surfacing newer entries.
|
|
166
|
+
*/
|
|
167
|
+
export function compareSemver(a, b) {
|
|
168
|
+
const left = parseSemver(a);
|
|
169
|
+
const right = parseSemver(b);
|
|
170
|
+
if (!left && !right)
|
|
171
|
+
return 0;
|
|
172
|
+
if (!left)
|
|
173
|
+
return -1;
|
|
174
|
+
if (!right)
|
|
175
|
+
return 1;
|
|
176
|
+
for (let i = 0; i < 3; i += 1) {
|
|
177
|
+
const cmp = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
|
178
|
+
if (cmp !== 0)
|
|
179
|
+
return cmp;
|
|
180
|
+
}
|
|
181
|
+
// A version without a pre-release tag is newer than the same core
|
|
182
|
+
// with a pre-release tag (per semver §11: 1.0.0 > 1.0.0-rc).
|
|
183
|
+
if (left.pre.length === 0 && right.pre.length === 0)
|
|
184
|
+
return 0;
|
|
185
|
+
if (left.pre.length === 0)
|
|
186
|
+
return 1;
|
|
187
|
+
if (right.pre.length === 0)
|
|
188
|
+
return -1;
|
|
189
|
+
const len = Math.max(left.pre.length, right.pre.length);
|
|
190
|
+
for (let i = 0; i < len; i += 1) {
|
|
191
|
+
const li = left.pre[i];
|
|
192
|
+
const ri = right.pre[i];
|
|
193
|
+
if (li === undefined)
|
|
194
|
+
return -1;
|
|
195
|
+
if (ri === undefined)
|
|
196
|
+
return 1;
|
|
197
|
+
const ln = Number.parseInt(li, 10);
|
|
198
|
+
const rn = Number.parseInt(ri, 10);
|
|
199
|
+
const liNumeric = Number.isFinite(ln) && String(ln) === li;
|
|
200
|
+
const riNumeric = Number.isFinite(rn) && String(rn) === ri;
|
|
201
|
+
if (liNumeric && riNumeric) {
|
|
202
|
+
if (ln !== rn)
|
|
203
|
+
return ln - rn;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (liNumeric)
|
|
207
|
+
return -1; // numeric < alphanumeric per §11
|
|
208
|
+
if (riNumeric)
|
|
209
|
+
return 1;
|
|
210
|
+
if (li < ri)
|
|
211
|
+
return -1;
|
|
212
|
+
if (li > ri)
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
function parseSemver(raw) {
|
|
218
|
+
if (typeof raw !== 'string')
|
|
219
|
+
return null;
|
|
220
|
+
const trimmed = raw.trim();
|
|
221
|
+
if (trimmed.length === 0)
|
|
222
|
+
return null;
|
|
223
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/u.exec(trimmed);
|
|
224
|
+
if (!match)
|
|
225
|
+
return null;
|
|
226
|
+
const major = Number.parseInt(match[1] ?? '0', 10);
|
|
227
|
+
const minor = Number.parseInt(match[2] ?? '0', 10);
|
|
228
|
+
const patch = Number.parseInt(match[3] ?? '0', 10);
|
|
229
|
+
const pre = match[4] ? match[4].split('.') : [];
|
|
230
|
+
return { core: [major, minor, patch], pre };
|
|
231
|
+
}
|
|
232
|
+
function trimBlankEdges(lines) {
|
|
233
|
+
let start = 0;
|
|
234
|
+
let end = lines.length;
|
|
235
|
+
while (start < end && (lines[start] ?? '').trim().length === 0)
|
|
236
|
+
start += 1;
|
|
237
|
+
while (end > start && (lines[end - 1] ?? '').trim().length === 0)
|
|
238
|
+
end -= 1;
|
|
239
|
+
return lines.slice(start, end);
|
|
240
|
+
}
|
|
241
|
+
//# sourceMappingURL=parser.js.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.pugi/.last-seen-version` state I/O — Leak L24 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* The `pugi release-notes` command renders the diff between the
|
|
5
|
+
* version the operator last saw notes for and the currently
|
|
6
|
+
* installed CLI version. This module owns the on-disk marker that
|
|
7
|
+
* tracks the last-seen value.
|
|
8
|
+
*
|
|
9
|
+
* # Module contract
|
|
10
|
+
*
|
|
11
|
+
* - File path: `<home>/.pugi/.last-seen-version`. The leading dot
|
|
12
|
+
* keeps it out of casual `ls` output; the file is plain ASCII
|
|
13
|
+
* (one line, the version string) so operators can edit it by
|
|
14
|
+
* hand when reproducing scenarios. Missing parent dir is
|
|
15
|
+
* created on write.
|
|
16
|
+
*
|
|
17
|
+
* - Reads: missing file → null. Unreadable / blank file → null.
|
|
18
|
+
* The caller treats null as "operator has never run the command"
|
|
19
|
+
* and surfaces every bundled section. Read failures NEVER throw —
|
|
20
|
+
* the command surface is informational and must keep working on
|
|
21
|
+
* a read-only mount or a misconfigured permission bit.
|
|
22
|
+
*
|
|
23
|
+
* - Writes: best-effort. EACCES / EROFS / ENOSPC log а warning к
|
|
24
|
+
* the caller-supplied logger and return the failure code so the
|
|
25
|
+
* command renderer can footer the output with "could not persist
|
|
26
|
+
* last-seen — re-run will show the same notes". The command
|
|
27
|
+
* itself stays exit 0 because the value of the render did not
|
|
28
|
+
* depend on the write succeeding.
|
|
29
|
+
*
|
|
30
|
+
* - The helpers are pure I/O wrappers — no clock, no random, no
|
|
31
|
+
* env reads. Callers pass `home` explicitly so the spec can
|
|
32
|
+
* pin a tmp dir without monkey-patching `os.homedir`.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { resolve } from 'node:path';
|
|
36
|
+
/** Filename inside `<home>/.pugi/` that holds the last-seen marker. */
|
|
37
|
+
export const LAST_SEEN_VERSION_FILE = '.last-seen-version';
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the absolute path of the last-seen marker file inside the
|
|
40
|
+
* passed home directory. Pure helper — no IO.
|
|
41
|
+
*/
|
|
42
|
+
export function lastSeenVersionPath(home) {
|
|
43
|
+
return resolve(home, '.pugi', LAST_SEEN_VERSION_FILE);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the last-seen marker. Returns null when the file is missing,
|
|
47
|
+
* unreadable, or empty. Never throws.
|
|
48
|
+
*/
|
|
49
|
+
export function readLastSeenVersion(home) {
|
|
50
|
+
const path = lastSeenVersionPath(home);
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(path))
|
|
53
|
+
return null;
|
|
54
|
+
const raw = readFileSync(path, 'utf8').trim();
|
|
55
|
+
if (raw.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
return raw;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Read-only mount, permission denied, race with another process
|
|
61
|
+
// unlinking the file — every read failure degrades к null so the
|
|
62
|
+
// command treats the operator as a first-time viewer instead of
|
|
63
|
+
// dropping out of the slash dispatcher with an unhandled error.
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Persist the last-seen marker. Creates `<home>/.pugi/` if it does
|
|
69
|
+
* not exist. Returns a structured envelope describing success or the
|
|
70
|
+
* failure reason so the renderer can footer а warning when the write
|
|
71
|
+
* could not be made durable.
|
|
72
|
+
*/
|
|
73
|
+
export function writeLastSeenVersion(home, version) {
|
|
74
|
+
const path = lastSeenVersionPath(home);
|
|
75
|
+
try {
|
|
76
|
+
const dir = resolve(home, '.pugi');
|
|
77
|
+
if (!existsSync(dir)) {
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
// Trailing newline so `cat ~/.pugi/.last-seen-version` reads
|
|
81
|
+
// nicely in shells that do not auto-append one for the prompt.
|
|
82
|
+
writeFileSync(path, `${version}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
83
|
+
return { status: 'ok', path };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'failed',
|
|
88
|
+
path,
|
|
89
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Clear the last-seen marker — used by `pugi release-notes --reset`
|
|
95
|
+
* so the operator can force the full bundled changelog к re-render.
|
|
96
|
+
* Returns `absent` when the marker did not exist, `cleared` on
|
|
97
|
+
* success, `failed` on permission errors.
|
|
98
|
+
*/
|
|
99
|
+
export function clearLastSeenVersion(home) {
|
|
100
|
+
const path = lastSeenVersionPath(home);
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(path)) {
|
|
103
|
+
return { status: 'absent', path };
|
|
104
|
+
}
|
|
105
|
+
unlinkSync(path);
|
|
106
|
+
return { status: 'cleared', path };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return {
|
|
110
|
+
status: 'failed',
|
|
111
|
+
path,
|
|
112
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=state.js.map
|