@pugi/cli 0.1.0-beta.22 → 0.1.0-beta.24

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * `pugi release-notes` — changelog diff between last-seen + current
3
+ * (Leak L24, 2026-05-27).
4
+ *
5
+ * Parity command with Claude Code's `/release-notes`, which shows
6
+ * what changed between the previously-installed CLI version and the
7
+ * currently-installed one. The Pugi variant reads the bundled
8
+ * `CHANGELOG.md`, slices it к the range `(last-seen, current]`, and
9
+ * renders the Markdown sections to the operator. After а successful
10
+ * render the marker is bumped к `current` so the next invocation is а
11
+ * no-op until the operator upgrades again.
12
+ *
13
+ * # Module contract
14
+ *
15
+ * - This file owns the WIRING from CLI flags + ambient state к the
16
+ * parser + state I/O helpers. The parser + state modules в
17
+ * `core/release-notes/` have zero coupling к the CLI dispatch
18
+ * surface.
19
+ *
20
+ * - `runReleaseNotesCommand` is the single entry point. Both the
21
+ * top-level `pugi release-notes` handler в `runtime/cli.ts` AND
22
+ * the in-REPL `/release-notes` slash command call it. The
23
+ * function returns а structured `ReleaseNotesResult` so the
24
+ * slash dispatcher can route the lines к the system pane
25
+ * without re-reading the changelog.
26
+ *
27
+ * - Exit code is ALWAYS 0. The command is informational, never а
28
+ * gate. Read failures, missing CHANGELOG, and write failures all
29
+ * degrade к а structured envelope with а human-readable footer.
30
+ *
31
+ * - The changelog source is captured behind а function so the spec
32
+ * can stub it without touching disk. The default reads the file
33
+ * bundled with the CLI install (resolved relative к the package
34
+ * root); fixtures pass an in-memory string.
35
+ *
36
+ * - `--reset` flag clears the last-seen marker AND re-renders the
37
+ * full bundled changelog as if the operator had never run the
38
+ * command. Distinct from а plain `--all` toggle because the
39
+ * reset PERSISTS (the next invocation again shows everything
40
+ * newer than the cleared marker — `none`).
41
+ */
42
+ import { existsSync, readFileSync } from 'node:fs';
43
+ import { homedir } from 'node:os';
44
+ import { dirname, resolve } from 'node:path';
45
+ import { fileURLToPath } from 'node:url';
46
+ import { parseChangelog, sliceVersionsBetween, } from '../../core/release-notes/parser.js';
47
+ import { clearLastSeenVersion, readLastSeenVersion, writeLastSeenVersion, } from '../../core/release-notes/state.js';
48
+ import { PUGI_CLI_VERSION } from '../version.js';
49
+ /**
50
+ * Default loader для the bundled `apps/pugi-cli/CHANGELOG.md`. The
51
+ * compiled bundle ships under `dist/runtime/commands/release-notes.js`;
52
+ * the CHANGELOG sits next к `package.json` at the package root, two
53
+ * directories up from `dist/runtime/commands/`. We also probe а
54
+ * couple of fallback locations so the dev path (running the source
55
+ * directly из `src/`) works без а compile step.
56
+ */
57
+ export function defaultReadChangelog() {
58
+ const candidates = resolveChangelogCandidates();
59
+ for (const candidate of candidates) {
60
+ try {
61
+ if (existsSync(candidate)) {
62
+ return readFileSync(candidate, 'utf8');
63
+ }
64
+ }
65
+ catch {
66
+ // Permission errors, transient FS hiccups — keep probing the
67
+ // remaining candidates. Returning null at the end is fine; the
68
+ // renderer surfaces а "changelog-missing" envelope.
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+ function resolveChangelogCandidates() {
74
+ // import.meta.url points к the compiled JS in production
75
+ // (`dist/runtime/commands/release-notes.js`) and к the source TS в
76
+ // tests / dev runs. We probe both relative ancestries so either
77
+ // path lands on `<package>/CHANGELOG.md`.
78
+ try {
79
+ const here = dirname(fileURLToPath(import.meta.url));
80
+ return [
81
+ resolve(here, '../../..', 'CHANGELOG.md'),
82
+ resolve(here, '../../../..', 'CHANGELOG.md'),
83
+ resolve(process.cwd(), 'apps/pugi-cli/CHANGELOG.md'),
84
+ resolve(process.cwd(), 'CHANGELOG.md'),
85
+ ];
86
+ }
87
+ catch {
88
+ // Some non-ESM contexts (very old node, eval'd code) reject
89
+ // `import.meta.url`. Fall back к cwd-relative probes — works for
90
+ // tests that run from the package root.
91
+ return [
92
+ resolve(process.cwd(), 'apps/pugi-cli/CHANGELOG.md'),
93
+ resolve(process.cwd(), 'CHANGELOG.md'),
94
+ ];
95
+ }
96
+ }
97
+ /**
98
+ * Default home dir resolver. Centralised so the CLI handler can call
99
+ * `runReleaseNotesCommand` without re-importing `os.homedir`.
100
+ */
101
+ export function defaultReleaseNotesHome() {
102
+ return homedir();
103
+ }
104
+ /**
105
+ * Pick + render the release notes, hand the result к the output
106
+ * sink, persist the marker. Always exits 0.
107
+ */
108
+ export function runReleaseNotesCommand(ctx) {
109
+ const readChangelog = ctx.readChangelog ?? defaultReadChangelog;
110
+ const currentVersion = ctx.currentVersion ?? PUGI_CLI_VERSION;
111
+ // `--reset` clears the marker before slicing. We capture the
112
+ // pre-clear value so the JSON envelope still shows the operator
113
+ // what their previous marker was, which makes scripting + bug
114
+ // reports easier.
115
+ const lastSeenBefore = readLastSeenVersion(ctx.home);
116
+ if (ctx.reset) {
117
+ clearLastSeenVersion(ctx.home);
118
+ }
119
+ const lastSeen = ctx.reset ? null : lastSeenBefore;
120
+ const raw = readChangelog();
121
+ if (raw === null) {
122
+ const result = {
123
+ command: 'release-notes',
124
+ currentVersion,
125
+ lastSeenVersion: lastSeenBefore ?? 'none',
126
+ sections: [],
127
+ status: 'changelog-missing',
128
+ markerPersisted: false,
129
+ persistFailure: null,
130
+ text: renderMissingChangelog(currentVersion),
131
+ };
132
+ ctx.writeOutput(result, result.text);
133
+ process.exitCode = 0;
134
+ return result;
135
+ }
136
+ const sections = parseChangelog(raw);
137
+ if (sections.length === 0) {
138
+ const result = {
139
+ command: 'release-notes',
140
+ currentVersion,
141
+ lastSeenVersion: lastSeenBefore ?? 'none',
142
+ sections: [],
143
+ status: 'changelog-empty',
144
+ markerPersisted: false,
145
+ persistFailure: null,
146
+ text: renderEmptyChangelog(currentVersion),
147
+ };
148
+ ctx.writeOutput(result, result.text);
149
+ process.exitCode = 0;
150
+ return result;
151
+ }
152
+ const slice = sliceVersionsBetween(sections, lastSeen, currentVersion);
153
+ if (slice.length === 0) {
154
+ // Nothing new — render the no-op message and DO NOT touch the
155
+ // marker (marker already equals current OR is newer; either way
156
+ // re-writing it is а no-op write we can avoid).
157
+ const result = {
158
+ command: 'release-notes',
159
+ currentVersion,
160
+ lastSeenVersion: lastSeenBefore ?? 'none',
161
+ sections: [],
162
+ status: 'up-to-date',
163
+ markerPersisted: false,
164
+ persistFailure: null,
165
+ text: renderUpToDate(currentVersion, lastSeenBefore),
166
+ };
167
+ ctx.writeOutput(result, result.text);
168
+ process.exitCode = 0;
169
+ return result;
170
+ }
171
+ const persist = writeLastSeenVersion(ctx.home, currentVersion);
172
+ const text = renderSections(slice, currentVersion, lastSeen, persist);
173
+ const result = {
174
+ command: 'release-notes',
175
+ currentVersion,
176
+ lastSeenVersion: lastSeenBefore ?? 'none',
177
+ sections: slice,
178
+ status: ctx.reset ? 'reset' : 'rendered',
179
+ markerPersisted: persist.status === 'ok',
180
+ persistFailure: persist.status === 'failed' ? persist.reason : null,
181
+ text,
182
+ };
183
+ ctx.writeOutput(result, text);
184
+ process.exitCode = 0;
185
+ return result;
186
+ }
187
+ function renderSections(sections, current, lastSeen, persist) {
188
+ const header = lastSeen
189
+ ? `Pugi release notes — ${lastSeen} → ${current}`
190
+ : `Pugi release notes — up to ${current}`;
191
+ const blocks = [header, '═'.repeat(Math.max(header.length, 30))];
192
+ for (const section of sections) {
193
+ const subhead = section.date
194
+ ? `## [${section.version}] - ${section.date}`
195
+ : `## [${section.version}]`;
196
+ blocks.push('');
197
+ blocks.push(subhead);
198
+ if (section.body.length > 0) {
199
+ blocks.push('');
200
+ blocks.push(section.body);
201
+ }
202
+ }
203
+ if (persist.status === 'failed') {
204
+ blocks.push('');
205
+ blocks.push(`Warning: could not persist last-seen marker (${persist.reason}). Next run will surface the same notes.`);
206
+ }
207
+ return blocks.join('\n');
208
+ }
209
+ function renderUpToDate(current, lastSeen) {
210
+ const lines = ['No new release notes.'];
211
+ lines.push(`Installed: ${current}`);
212
+ lines.push(`Last seen: ${lastSeen ?? 'none'}`);
213
+ return lines.join('\n');
214
+ }
215
+ function renderMissingChangelog(current) {
216
+ return [
217
+ 'Release notes are not bundled with this install.',
218
+ `Installed: ${current}`,
219
+ 'See https://pugi.io/changelog for the rendered changelog.',
220
+ ].join('\n');
221
+ }
222
+ function renderEmptyChangelog(current) {
223
+ return [
224
+ 'Bundled changelog is empty — no parsed sections.',
225
+ `Installed: ${current}`,
226
+ 'See https://pugi.io/changelog for the rendered changelog.',
227
+ ].join('\n');
228
+ }
229
+ //# sourceMappingURL=release-notes.js.map
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Leak L30 (2026-05-27) — `pugi theme` top-level command + REPL slash
3
+ * companion.
4
+ *
5
+ * Operator surface:
6
+ *
7
+ * pugi theme Show active theme + table.
8
+ * pugi theme <name> Switch workspace theme (current cwd).
9
+ * pugi theme <name> --persist Switch + also write user default.
10
+ * pugi theme --reset Clear workspace override → back to default.
11
+ * pugi theme --reset --user Also clear the user default.
12
+ * pugi theme --list Print the catalogue (no flip).
13
+ * pugi theme --json Structured envelope variant.
14
+ *
15
+ * The same runner powers `/theme` from inside the REPL. The REPL
16
+ * dispatcher (see `core/repl/session.ts`) routes through here so the
17
+ * two surfaces stay single-sourced — operators trained on one read
18
+ * the same payload + table on the other. Matches the leak L18
19
+ * `/style` runner exactly so the two settings surfaces are
20
+ * paste-comparable for the operator + grep-comparable for future
21
+ * maintenance.
22
+ *
23
+ * Exit codes:
24
+ * 0 — show / switch / reset all succeed
25
+ * 1 — unknown preset slug (returned BEFORE any write)
26
+ * 2 — conflicting flags (e.g. `--reset` with a positional slug)
27
+ *
28
+ * The exit codes are surfaced through `process.exitCode` by the
29
+ * dispatcher in `cli.ts` — this module returns a structured payload
30
+ * + writes via the injected `writeOutput`. Throwing is reserved for
31
+ * truly unexpected errors (fs permissions etc.); the spec hooks the
32
+ * happy + sad paths through `writeOutput` shape, not via try/catch
33
+ * on the throw.
34
+ */
35
+ import { DEFAULT_THEME, isThemeSlug, renderThemeTable, THEME_SLUGS, } from '../../core/theme/presets.js';
36
+ import { clearUserTheme, clearWorkspaceTheme, resolveTheme, setUserTheme, setWorkspaceTheme, } from '../../core/theme/state.js';
37
+ /**
38
+ * Entry point. Parses `args`, applies the operation, emits the
39
+ * payload + text via `ctx.writeOutput`, and returns the exit code the
40
+ * dispatcher should hand back to the shell.
41
+ */
42
+ export async function runThemeCommand(args, ctx) {
43
+ const flags = parseFlags(args);
44
+ // Reset path
45
+ if (flags.reset) {
46
+ if (flags.slug !== null) {
47
+ const payload = buildPayload({
48
+ status: 'invalid_flags',
49
+ ctx,
50
+ message: '/theme --reset cannot be combined with a preset name. Use one or the other.',
51
+ });
52
+ ctx.writeOutput(payload, payload.message);
53
+ return 2;
54
+ }
55
+ if (flags.persist) {
56
+ const payload = buildPayload({
57
+ status: 'invalid_flags',
58
+ ctx,
59
+ message: '/theme --reset cannot be combined with --persist. Use --reset --user to also clear the user default.',
60
+ });
61
+ ctx.writeOutput(payload, payload.message);
62
+ return 2;
63
+ }
64
+ clearWorkspaceTheme({ workspaceRoot: ctx.workspaceRoot, env: ctx.env });
65
+ if (flags.user) {
66
+ clearUserTheme({ workspaceRoot: ctx.workspaceRoot, env: ctx.env });
67
+ }
68
+ const payload = buildPayload({
69
+ status: 'reset',
70
+ ctx,
71
+ message: flags.user
72
+ ? `Cleared workspace + user theme. Active: ${DEFAULT_THEME} (default).`
73
+ : `Cleared workspace theme. Active: ${describeActive(ctx)}.`,
74
+ });
75
+ ctx.writeOutput(payload, payload.message);
76
+ return 0;
77
+ }
78
+ // List path
79
+ if (flags.list && flags.slug === null) {
80
+ const resolved = resolveTheme({ workspaceRoot: ctx.workspaceRoot, env: ctx.env });
81
+ const payload = buildPayload({
82
+ status: 'listed',
83
+ ctx,
84
+ message: renderThemeTable(resolved.slug),
85
+ });
86
+ ctx.writeOutput(payload, payload.message);
87
+ return 0;
88
+ }
89
+ // Switch path
90
+ if (flags.slug !== null) {
91
+ if (!isThemeSlug(flags.slug)) {
92
+ const payload = buildPayload({
93
+ status: 'invalid_slug',
94
+ ctx,
95
+ attemptedSlug: flags.slug,
96
+ message: `Unknown theme "${flags.slug}". Try one of: ${THEME_SLUGS.join(', ')}.`,
97
+ });
98
+ ctx.writeOutput(payload, payload.message);
99
+ return 1;
100
+ }
101
+ const before = resolveTheme({ workspaceRoot: ctx.workspaceRoot, env: ctx.env });
102
+ setWorkspaceTheme(flags.slug, { workspaceRoot: ctx.workspaceRoot, env: ctx.env });
103
+ if (flags.persist) {
104
+ setUserTheme(flags.slug, { workspaceRoot: ctx.workspaceRoot, env: ctx.env });
105
+ }
106
+ const tail = flags.persist ? ' (workspace + user default)' : ' (workspace)';
107
+ const payload = buildPayload({
108
+ status: 'switched',
109
+ ctx,
110
+ previous: before.slug,
111
+ persistedToUser: flags.persist,
112
+ message: `Theme → ${flags.slug}${tail}. Was: ${before.slug} (${before.source}).`,
113
+ });
114
+ ctx.writeOutput(payload, payload.message);
115
+ return 0;
116
+ }
117
+ // Show path (no args)
118
+ const resolved = resolveTheme({ workspaceRoot: ctx.workspaceRoot, env: ctx.env });
119
+ const banner = `Active theme: ${resolved.slug} (${resolved.source})`;
120
+ const table = renderThemeTable(resolved.slug);
121
+ const payload = buildPayload({
122
+ status: 'show',
123
+ ctx,
124
+ message: `${banner}\n\n${table}`,
125
+ });
126
+ ctx.writeOutput(payload, payload.message);
127
+ return 0;
128
+ }
129
+ function describeActive(ctx) {
130
+ const resolved = resolveTheme({ workspaceRoot: ctx.workspaceRoot, env: ctx.env });
131
+ return `${resolved.slug} (${resolved.source})`;
132
+ }
133
+ function buildPayload(args) {
134
+ const resolved = resolveTheme({ workspaceRoot: args.ctx.workspaceRoot, env: args.ctx.env });
135
+ const payload = {
136
+ command: 'theme',
137
+ status: args.status,
138
+ active: resolved.slug,
139
+ source: resolved.source,
140
+ presets: THEME_SLUGS,
141
+ message: args.message,
142
+ };
143
+ if (args.previous !== undefined)
144
+ payload.previous = args.previous;
145
+ if (args.persistedToUser !== undefined)
146
+ payload.persistedToUser = args.persistedToUser;
147
+ if (args.attemptedSlug !== undefined)
148
+ payload.attemptedSlug = args.attemptedSlug;
149
+ return payload;
150
+ }
151
+ function parseFlags(args) {
152
+ const flags = {
153
+ slug: null,
154
+ persist: false,
155
+ reset: false,
156
+ user: false,
157
+ list: false,
158
+ };
159
+ for (const arg of args) {
160
+ if (arg === '--persist')
161
+ flags.persist = true;
162
+ else if (arg === '--reset')
163
+ flags.reset = true;
164
+ else if (arg === '--user')
165
+ flags.user = true;
166
+ else if (arg === '--list')
167
+ flags.list = true;
168
+ else if (arg.startsWith('-')) {
169
+ // Unknown flag — keep simple parser. Treat as positional so the
170
+ // downstream isThemeSlug check rejects it with a clear "unknown
171
+ // theme" message rather than swallowing silently. Mirrors the
172
+ // L18 style runner's behaviour for grep-parity.
173
+ if (flags.slug === null)
174
+ flags.slug = arg;
175
+ }
176
+ else if (flags.slug === null) {
177
+ flags.slug = arg;
178
+ }
179
+ }
180
+ // Normalise the slug to lowercase so `pugi theme DARK` works the
181
+ // same as `pugi theme dark`. The catalogue is lowercase-only by
182
+ // contract; this keeps operators from tripping on shift-key habits.
183
+ if (flags.slug !== null)
184
+ flags.slug = flags.slug.toLowerCase();
185
+ return flags;
186
+ }
187
+ /**
188
+ * Re-export for the slash-command dispatcher in
189
+ * `core/repl/session.ts` so it can render a preview block if a
190
+ * future surface (`/theme --preview`) lands. Kept here so the
191
+ * runtime module is the single import point for theme-related
192
+ * surfaces; consumers should NOT reach into `core/theme/*` directly
193
+ * unless they are Ink components that need the React context.
194
+ */
195
+ export { THEMES as THEME_CATALOGUE, } from '../../core/theme/presets.js';
196
+ //# sourceMappingURL=theme.js.map