@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,238 @@
1
+ /**
2
+ * `pugi login --provider env` — env-var auth path (Leak L35).
3
+ *
4
+ * Claude Code, Codex CLI, and gh CLI all ship a way to authenticate via
5
+ * an environment variable so CI / container / scripted contexts can
6
+ * skip the device flow entirely. This module backs that path:
7
+ *
8
+ * 1. Resolve the candidate token (explicit `--key` flag beats
9
+ * `PUGI_API_KEY` env — same precedence as `gh auth login --token`).
10
+ * 2. Run a cheap local format check so an obviously malformed key
11
+ * (empty, whitespace, suspiciously short) fails fast WITHOUT
12
+ * shipping it to the server (no observability leak into the
13
+ * Anvil access log).
14
+ * 3. Call `GET /api/pugi/health` with `Authorization: Bearer <key>`
15
+ * so an expired / revoked / typo'd token surfaces immediately
16
+ * and the credential file never lands on disk for a dead key.
17
+ * 4. Map response to typed outcome the CLI dispatcher can render.
18
+ *
19
+ * The module is intentionally pure — fetch + reading env are injected,
20
+ * the writer is a separate concern. The CLI dispatcher composes
21
+ * `resolveEnvCandidateToken` + `assertTokenFormat` + `validateTokenAgainstHealth`
22
+ * and then writes the credential via `storeApiKey` on success.
23
+ *
24
+ * Failure modes are explicit so the dispatcher can pick the user-facing
25
+ * remediation string without re-parsing strings:
26
+ *
27
+ * - `missing` → no token in env or --key, halt with hint
28
+ * - `invalid-format` → token failed local format check, halt
29
+ * - `unauthorized` → server rejected the token (401 / 403)
30
+ * - `network-error` → fetch threw (DNS, refused, TLS)
31
+ * - `server-error` → server returned 5xx (transient — operator
32
+ * may want to retry once)
33
+ * - `unexpected-status`→ anything else non-2xx (treat as failure)
34
+ *
35
+ * NEVER log the raw token. Memory hits
36
+ * `feedback_no_claude_attribution_anywhere_hard_rule` plus the
37
+ * CSO bearer-leak sweep apply here. Use `maskApiKey` from
38
+ * `core/credentials.ts` when the dispatcher needs to surface the key
39
+ * to the operator.
40
+ */
41
+ /**
42
+ * The minimum length below which we refuse to even ship the token to
43
+ * the server. Pugi-issued PATs are 48+ chars (`pugi_<32 base32>`), JWTs
44
+ * issued by the device flow are ~250 chars, legacy `sk-*` PATs we
45
+ * accept for compatibility are 32+. 16 is well below all three real
46
+ * shapes so it only catches obvious paste mistakes.
47
+ */
48
+ export const MIN_TOKEN_LENGTH = 16;
49
+ /**
50
+ * The set of prefixes we recognise as plausibly-real Pugi-shaped
51
+ * tokens. Loose by design — the real validator is the server-side
52
+ * health probe. We just want to catch an operator who pasted the
53
+ * wrong string entirely (a username, a URL, a placeholder like
54
+ * "<your-key>") before it reaches the network.
55
+ *
56
+ * Three-segment JWTs are also accepted via the `looksLikeJwt`
57
+ * predicate so device-flow tokens copied out of `~/.pugi/credentials.json`
58
+ * on a different machine work.
59
+ */
60
+ export const RECOGNISED_TOKEN_PREFIXES = ['pugi_', 'sk_', 'sk-', 'pat_'];
61
+ /**
62
+ * Returns the trimmed candidate token, or `null` when neither path
63
+ * produced one. Precedence: explicit flag arg beats env var (matches
64
+ * `gh auth login --with-token`, `aws configure set`, and `pugi config`
65
+ * which all prefer the most-specific operator intent over the ambient
66
+ * env).
67
+ */
68
+ export function resolveEnvCandidateToken(input) {
69
+ const explicit = input.explicitKey?.trim();
70
+ if (explicit)
71
+ return explicit;
72
+ const env = input.env ?? process.env;
73
+ const fromEnv = env.PUGI_API_KEY?.trim();
74
+ if (fromEnv)
75
+ return fromEnv;
76
+ return null;
77
+ }
78
+ /**
79
+ * Local-only format check. Returns `null` on accept, a human-readable
80
+ * error string on reject. Deliberately lenient — the server-side
81
+ * health probe is the source of truth. We only catch obvious paste
82
+ * mistakes (empty, whitespace-laden, too short, looks like a URL or
83
+ * a placeholder).
84
+ */
85
+ export function assertTokenFormat(token) {
86
+ if (!token)
87
+ return 'Token is empty';
88
+ if (/\s/.test(token)) {
89
+ return 'Token contains whitespace — check for shell quoting issues or a stray newline';
90
+ }
91
+ if (token.length < MIN_TOKEN_LENGTH) {
92
+ return `Token too short (${token.length} chars; Pugi tokens are >= ${MIN_TOKEN_LENGTH})`;
93
+ }
94
+ if (token.startsWith('<') && token.endsWith('>')) {
95
+ return 'Token looks like a placeholder (`<your-key>`) — replace with the actual key';
96
+ }
97
+ if (/^https?:\/\//i.test(token)) {
98
+ return 'Token looks like a URL — did you mean --api-url?';
99
+ }
100
+ // Accept either a recognised prefix OR a JWT three-segment shape.
101
+ // Anything else still passes — the server probe will catch genuinely
102
+ // unknown keys. We just want to surface an obvious mistake.
103
+ const hasKnownPrefix = RECOGNISED_TOKEN_PREFIXES.some((p) => token.startsWith(p));
104
+ if (!hasKnownPrefix && !looksLikeJwt(token)) {
105
+ // Soft-fail: warn the operator but proceed. Returning null here
106
+ // would mask the case where the operator pasted something
107
+ // genuinely wrong but the server happens to accept it (impossible
108
+ // for real keys but defence-in-depth). Returning the warning
109
+ // string would block legacy keys. We choose to proceed — the
110
+ // server is the source of truth — and let the CLI dispatcher
111
+ // decide whether to surface a note. Tracked via a separate
112
+ // `warnUnknownPrefix` return on a future revision.
113
+ return null;
114
+ }
115
+ return null;
116
+ }
117
+ /**
118
+ * JWT three-segment check. Does NOT verify the signature — we just
119
+ * want to recognise the shape so device-flow tokens copied from one
120
+ * machine to another pass the format gate.
121
+ */
122
+ export function looksLikeJwt(token) {
123
+ const parts = token.split('.');
124
+ if (parts.length !== 3)
125
+ return false;
126
+ return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 0);
127
+ }
128
+ /**
129
+ * Call `GET /api/pugi/health` with the candidate token. Returns a
130
+ * typed outcome that the CLI dispatcher can map directly to an exit
131
+ * code + remediation string.
132
+ *
133
+ * Health endpoint conventions (see apps/admin-api):
134
+ * - 200 → token is valid, account is active
135
+ * - 401 → token unknown / malformed at the server boundary
136
+ * - 403 → token recognised but the account is suspended / paused
137
+ * - 5xx → server-side issue, operator can retry
138
+ * - network throw → DNS, refused, TLS — operator's connectivity issue
139
+ *
140
+ * We do not parse the body — the health endpoint's contract is the
141
+ * status code. Any future field (latency, region, build sha) can be
142
+ * surfaced by a separate `pugi doctor` probe without touching the
143
+ * login path.
144
+ */
145
+ export async function validateTokenAgainstHealth(input) {
146
+ const fetchImpl = input.fetchImpl ?? fetch;
147
+ const now = input.now ?? Date.now;
148
+ const url = `${stripTrailingSlash(input.apiUrl)}/api/pugi/health`;
149
+ const started = now();
150
+ let response;
151
+ try {
152
+ response = await fetchImpl(url, {
153
+ method: 'GET',
154
+ headers: {
155
+ Authorization: `Bearer ${input.apiKey}`,
156
+ Accept: 'application/json',
157
+ },
158
+ });
159
+ }
160
+ catch (error) {
161
+ // DNS failure, ECONNREFUSED, TLS handshake — anything that makes
162
+ // fetch throw before a status code is observable. We deliberately
163
+ // do NOT echo the URL host in the message body if it could leak a
164
+ // self-hosted Anvil hostname into a public CI log; the dispatcher
165
+ // composes the user-facing remediation.
166
+ const cause = error instanceof Error ? error.message : String(error);
167
+ return {
168
+ kind: 'network-error',
169
+ message: `Cannot reach ${input.apiUrl}; check your connection`,
170
+ cause,
171
+ };
172
+ }
173
+ const latencyMs = now() - started;
174
+ const { status } = response;
175
+ if (status === 200) {
176
+ return { kind: 'ok', latencyMs };
177
+ }
178
+ if (status === 401 || status === 403) {
179
+ return {
180
+ kind: 'unauthorized',
181
+ status,
182
+ message: status === 401
183
+ ? 'Token invalid or expired — run `pugi login --provider device` to get a fresh one'
184
+ : 'Token recognised but the account is suspended — check `pugi whoami` on a working machine or contact support',
185
+ };
186
+ }
187
+ if (status >= 500) {
188
+ return {
189
+ kind: 'server-error',
190
+ status,
191
+ message: `${input.apiUrl} returned ${status}; retry in a moment`,
192
+ };
193
+ }
194
+ return {
195
+ kind: 'unexpected-status',
196
+ status,
197
+ message: `Unexpected ${status} from /api/pugi/health; treat as login failure`,
198
+ };
199
+ }
200
+ export async function resolveAndValidateEnvLogin(input) {
201
+ const token = resolveEnvCandidateToken({
202
+ explicitKey: input.explicitKey,
203
+ env: input.env,
204
+ });
205
+ if (!token) {
206
+ return {
207
+ kind: 'missing',
208
+ message: 'pugi login --provider env requires a token. Export PUGI_API_KEY in the current shell or pass --key <value>.',
209
+ };
210
+ }
211
+ const formatError = assertTokenFormat(token);
212
+ if (formatError) {
213
+ return {
214
+ kind: 'invalid-format',
215
+ message: `pugi login --provider env: ${formatError}`,
216
+ };
217
+ }
218
+ if (input.skipValidate) {
219
+ // Used by the existing `login-variants.spec.ts` regression suite
220
+ // so the test plane does not require a live network. Production
221
+ // path always validates.
222
+ return { kind: 'ok', token, latencyMs: 0 };
223
+ }
224
+ const probe = await validateTokenAgainstHealth({
225
+ apiUrl: input.apiUrl,
226
+ apiKey: token,
227
+ fetchImpl: input.fetchImpl,
228
+ now: input.now,
229
+ });
230
+ if (probe.kind === 'ok') {
231
+ return { kind: 'ok', token, latencyMs: probe.latencyMs };
232
+ }
233
+ return probe;
234
+ }
235
+ function stripTrailingSlash(url) {
236
+ return url.endsWith('/') ? url.slice(0, -1) : url;
237
+ }
238
+ //# sourceMappingURL=env-provider.js.map
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Leak L27 (2026-05-27) — Auto-update channel model.
3
+ *
4
+ * Claude Code's self-update flow exposes three release channels (stable
5
+ * / beta / canary) so operators can dial in their own risk tolerance:
6
+ * a finance-team operator pins `stable`, a tinkerer rides `canary`,
7
+ * and the default `beta` track sits in the middle. Pugi parity ships
8
+ * the same vocabulary on top of npm's dist-tags so we do NOT need a
9
+ * custom registry hop:
10
+ *
11
+ * - `stable` → npm dist-tag `latest`
12
+ * - `beta` → npm dist-tag `beta`
13
+ * - `canary` → npm dist-tag `next`
14
+ *
15
+ * The mapping is centralised here because the dispatcher + the probe
16
+ * + the slash command + the doctor probe all need to agree on which
17
+ * tag they query. The channel name is what shows up in operator UI
18
+ * (`pugi update --channel beta`); the npm tag is the wire-format that
19
+ * gets concatenated into `npm i -g @pugi/cli@<tag>`.
20
+ *
21
+ * Module contract:
22
+ *
23
+ * - Pure constants + a small parse helper. No I/O, no module-level
24
+ * side effects. The persistence layer (`state.ts`) and the probe
25
+ * (`checker.ts`) wrap this module without ever mutating it.
26
+ *
27
+ * - Default channel is `beta` because Pugi currently publishes ONLY
28
+ * beta releases on npm. Defaulting to `stable` would leave fresh
29
+ * installs polling a tag that does not exist yet, surfacing a
30
+ * confusing "no update available" message even though the binary
31
+ * they just installed is already behind. The default is revisited
32
+ * when `latest` (stable) starts shipping; the constant lives on
33
+ * a single line so the bump is a one-character diff.
34
+ *
35
+ * - All channel <-> tag conversions go through `npmTagForChannel` /
36
+ * `channelForNpmTag`. Hard-coding the string `'beta'` anywhere
37
+ * else is a bug.
38
+ */
39
+ /**
40
+ * Immutable list of every supported channel. Drives the `--channel`
41
+ * flag validator + the `/update --channel <name>` slash parser +
42
+ * any future Ink picker that wants to render the full set.
43
+ */
44
+ export const UPDATE_CHANNELS = Object.freeze([
45
+ 'stable',
46
+ 'beta',
47
+ 'canary',
48
+ ]);
49
+ /**
50
+ * Default channel when neither `~/.pugi/config.json::updateChannel`
51
+ * nor a CLI flag specifies one. See module header for rationale.
52
+ */
53
+ export const DEFAULT_UPDATE_CHANNEL = 'beta';
54
+ /**
55
+ * npm dist-tag → channel mapping. Centralised so a future channel
56
+ * rename (e.g. dropping `next` in favour of `canary` as the npm tag)
57
+ * is a single-line change. The reverse-lookup helpers below derive
58
+ * from this map so the two surfaces never drift.
59
+ */
60
+ const CHANNEL_TO_NPM_TAG = Object.freeze({
61
+ stable: 'latest',
62
+ beta: 'beta',
63
+ canary: 'next',
64
+ });
65
+ /**
66
+ * Map a Pugi channel name to the npm dist-tag the registry exposes.
67
+ * Used to build both the registry query URL and the
68
+ * `npm i -g @pugi/cli@<tag>` install command surfaced to the operator.
69
+ */
70
+ export function npmTagForChannel(channel) {
71
+ return CHANNEL_TO_NPM_TAG[channel];
72
+ }
73
+ /**
74
+ * Reverse lookup: given an npm dist-tag, return the matching Pugi
75
+ * channel name. Returns `null` for any tag we do not officially
76
+ * support; the caller layers in its own fallback (typically
77
+ * `DEFAULT_UPDATE_CHANNEL`) so a one-off dist-tag never crashes the
78
+ * channel picker.
79
+ */
80
+ export function channelForNpmTag(tag) {
81
+ for (const channel of UPDATE_CHANNELS) {
82
+ if (CHANNEL_TO_NPM_TAG[channel] === tag)
83
+ return channel;
84
+ }
85
+ return null;
86
+ }
87
+ /**
88
+ * Type guard / parser for an unknown string. Trims + lowercases the
89
+ * input so `--channel BETA` and `--channel beta ` both work. Returns
90
+ * `null` for unknown channel names; the caller decides whether to
91
+ * fall back, error out, or prompt the operator.
92
+ */
93
+ export function parseUpdateChannel(raw) {
94
+ if (typeof raw !== 'string')
95
+ return null;
96
+ const normalised = raw.trim().toLowerCase();
97
+ if (normalised.length === 0)
98
+ return null;
99
+ for (const channel of UPDATE_CHANNELS) {
100
+ if (channel === normalised)
101
+ return channel;
102
+ }
103
+ return null;
104
+ }
105
+ /**
106
+ * Pretty operator-readable description of a channel. Surfaced in the
107
+ * `pugi update --check` JSON envelope + the interactive picker so the
108
+ * operator sees what they are switching into. Keeps the copy single-
109
+ * sourced because the same line lands in the doctor table, the
110
+ * `--check` JSON, and the slash-command response.
111
+ */
112
+ export function describeChannel(channel) {
113
+ switch (channel) {
114
+ case 'stable':
115
+ return 'stable (npm latest — finance / prod operators)';
116
+ case 'beta':
117
+ return 'beta (npm beta — current Pugi default)';
118
+ case 'canary':
119
+ return 'canary (npm next — tinkerers, early adopters)';
120
+ }
121
+ }
122
+ //# sourceMappingURL=channels.js.map
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Leak L27 (2026-05-27) — Channel-aware npm registry probe.
3
+ *
4
+ * Polls `https://registry.npmjs.org/@pugi/cli` and reads the
5
+ * `dist-tags` object — npm publishes one entry per dist-tag (`latest`,
6
+ * `beta`, `next`) mapped to the highest semver under that tag. The
7
+ * channel resolver in `channels.ts` translates Pugi's user-visible
8
+ * channel name (`stable` / `beta` / `canary`) into the matching tag
9
+ * before we read it off the response.
10
+ *
11
+ * Why this lives next to (rather than INSIDE) the existing
12
+ * `runtime/update-check.ts`:
13
+ *
14
+ * - `update-check.ts` already poll `/@pugi/cli/latest`. That endpoint
15
+ * ONLY surfaces the `latest` dist-tag — channel selection requires
16
+ * the full package document `/@pugi/cli` so we can pluck any of
17
+ * the three tags. Two endpoints, two probes.
18
+ *
19
+ * - The legacy banner (REPL cold start) keeps polling `latest` and
20
+ * does NOT need to know about channels yet — it is a single-track
21
+ * cosmetic hint. Splitting into two modules means we can ship L27
22
+ * without churning the existing banner cache shape.
23
+ *
24
+ * - A future sprint may unify the two probes once the channel
25
+ * selection percolates into the REPL banner. For now the modules
26
+ * coexist with a clear boundary: this file owns the `pugi update`
27
+ * surface, `runtime/update-check.ts` owns the REPL cold-start
28
+ * cosmetic banner.
29
+ *
30
+ * Module contract:
31
+ *
32
+ * - The probe is pure with respect to disk + clock — every IO/clock
33
+ * dependency is injected. The same module is used both at the CLI
34
+ * entry point (real `fetch` + real `Date.now`) and in tests
35
+ * (mock fetch + frozen clock) without a single environment shim.
36
+ *
37
+ * - Failures NEVER throw. A 5xx, a network error, a JSON parse
38
+ * failure, or an unknown channel/tag all collapse to a structured
39
+ * `{ available: false, error: <reason> }` envelope. The caller
40
+ * decides whether to surface the error to the operator (the
41
+ * `pugi update --check` JSON surface) or swallow it (the cold-
42
+ * start banner).
43
+ *
44
+ * - The probe DOES NOT touch persistence. Writing the
45
+ * `~/.pugi/.last-update-check` timestamp is the caller's
46
+ * responsibility (the dispatcher in `runtime/commands/update.ts`)
47
+ * so a smoke-test sweep that probes the registry 50 times in a
48
+ * row does not accidentally write 50 timestamps.
49
+ */
50
+ import { npmTagForChannel, } from './channels.js';
51
+ import { compareVersions } from '../../runtime/update-check.js';
52
+ /**
53
+ * Base registry URL — overridable per call so the spec can drive the
54
+ * full request lifecycle through undici's MockAgent.
55
+ */
56
+ const PUGI_CLI_PACKAGE_URL = 'https://registry.npmjs.org/@pugi/cli';
57
+ /**
58
+ * Hard cap on the registry round-trip. Mirrors the existing
59
+ * `runtime/update-check.ts::FETCH_TIMEOUT_MS` so both surfaces
60
+ * timeout-by-the-same-budget — operators never wait longer for `pugi
61
+ * update --check` than they would for the cold-start banner.
62
+ */
63
+ const FETCH_TIMEOUT_MS = 3_000;
64
+ /**
65
+ * Build the install command the operator copy-pastes (or `pugi update
66
+ * --apply` shells out to). Centralised so the dispatcher + the
67
+ * checker share a single source of truth on the literal string.
68
+ */
69
+ export function buildInstallCommand(channel) {
70
+ return `npm install -g @pugi/cli@${npmTagForChannel(channel)}`;
71
+ }
72
+ /**
73
+ * Probe the npm registry for the latest version under `channel`. The
74
+ * dispatcher wraps this with persistence + confirmation prompts; this
75
+ * function is intentionally pure so the doctor probe + the cold-
76
+ * start banner can reuse it without dragging the dispatcher's I/O
77
+ * surface in.
78
+ */
79
+ export async function checkForChannelUpdate(deps) {
80
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch.bind(globalThis);
81
+ const registryUrl = deps.registryUrl ?? PUGI_CLI_PACKAGE_URL;
82
+ const timeoutMs = deps.timeoutMs ?? FETCH_TIMEOUT_MS;
83
+ const tag = npmTagForChannel(deps.channel);
84
+ const installCommand = buildInstallCommand(deps.channel);
85
+ // A controller-driven timeout — the spec's MockAgent never trips it
86
+ // but the production path on a stuck registry must bail in 3s.
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
89
+ let response;
90
+ try {
91
+ response = await fetchImpl(registryUrl, {
92
+ method: 'GET',
93
+ headers: { Accept: 'application/json' },
94
+ signal: controller.signal,
95
+ });
96
+ }
97
+ catch (error) {
98
+ clearTimeout(timer);
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ return {
101
+ channel: deps.channel,
102
+ npmTag: tag,
103
+ current: deps.currentVersion,
104
+ latest: null,
105
+ available: false,
106
+ gap: null,
107
+ installCommand,
108
+ error: `registry_unreachable: ${message}`,
109
+ };
110
+ }
111
+ clearTimeout(timer);
112
+ if (!response.ok) {
113
+ return {
114
+ channel: deps.channel,
115
+ npmTag: tag,
116
+ current: deps.currentVersion,
117
+ latest: null,
118
+ available: false,
119
+ gap: null,
120
+ installCommand,
121
+ error: `registry_status_${response.status}`,
122
+ };
123
+ }
124
+ let body;
125
+ try {
126
+ body = await response.json();
127
+ }
128
+ catch (error) {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ return {
131
+ channel: deps.channel,
132
+ npmTag: tag,
133
+ current: deps.currentVersion,
134
+ latest: null,
135
+ available: false,
136
+ gap: null,
137
+ installCommand,
138
+ error: `registry_json_unparseable: ${message}`,
139
+ };
140
+ }
141
+ const distTags = extractDistTags(body);
142
+ if (!distTags) {
143
+ return {
144
+ channel: deps.channel,
145
+ npmTag: tag,
146
+ current: deps.currentVersion,
147
+ latest: null,
148
+ available: false,
149
+ gap: null,
150
+ installCommand,
151
+ error: 'registry_missing_dist_tags',
152
+ };
153
+ }
154
+ const latest = distTags[tag];
155
+ if (typeof latest !== 'string' || latest.length === 0) {
156
+ return {
157
+ channel: deps.channel,
158
+ npmTag: tag,
159
+ current: deps.currentVersion,
160
+ latest: null,
161
+ available: false,
162
+ gap: null,
163
+ installCommand,
164
+ error: `registry_missing_tag_${tag}`,
165
+ };
166
+ }
167
+ const cmp = compareVersions(deps.currentVersion, latest);
168
+ const available = cmp < 0;
169
+ const gap = classifyGap(deps.currentVersion, latest);
170
+ return {
171
+ channel: deps.channel,
172
+ npmTag: tag,
173
+ current: deps.currentVersion,
174
+ latest,
175
+ available,
176
+ gap,
177
+ installCommand,
178
+ error: null,
179
+ };
180
+ }
181
+ /**
182
+ * Extract the `dist-tags` object from a registry response. The npm
183
+ * package document is large (often >100kB); we only need this one
184
+ * key. Returns null when the field is missing or not a string-record
185
+ * — both indicate a registry response the probe should treat as
186
+ * unusable rather than panic on.
187
+ */
188
+ function extractDistTags(body) {
189
+ if (!body || typeof body !== 'object')
190
+ return null;
191
+ const obj = body;
192
+ const raw = obj['dist-tags'];
193
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
194
+ return null;
195
+ const result = {};
196
+ for (const [tag, value] of Object.entries(raw)) {
197
+ if (typeof value === 'string' && value.length > 0) {
198
+ result[tag] = value;
199
+ }
200
+ }
201
+ return result;
202
+ }
203
+ /**
204
+ * Coarse semver gap classification used to colour the dispatcher's
205
+ * diff line. We do NOT roll our own semver parser — `compareVersions`
206
+ * (already in `runtime/update-check.ts`) is the canonical comparator;
207
+ * here we only need to bucket the diff into major / minor / patch /
208
+ * prerelease for UX hints.
209
+ *
210
+ * The classifier is intentionally tolerant: anything that does not
211
+ * parse as `X.Y.Z[-pre]` collapses to `prerelease` so we surface a
212
+ * conservative "investigate manually" hint instead of crashing.
213
+ */
214
+ function classifyGap(current, latest) {
215
+ if (current === latest)
216
+ return 'same';
217
+ const a = parseCoarseSemver(current);
218
+ const b = parseCoarseSemver(latest);
219
+ if (!a || !b)
220
+ return 'prerelease';
221
+ if (b.major !== a.major)
222
+ return 'major';
223
+ if (b.minor !== a.minor)
224
+ return 'minor';
225
+ if (b.patch !== a.patch)
226
+ return 'patch';
227
+ return 'prerelease';
228
+ }
229
+ function parseCoarseSemver(version) {
230
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version);
231
+ if (!match)
232
+ return null;
233
+ const major = Number(match[1]);
234
+ const minor = Number(match[2]);
235
+ const patch = Number(match[3]);
236
+ if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
237
+ return null;
238
+ }
239
+ return { major, minor, patch };
240
+ }
241
+ //# sourceMappingURL=checker.js.map