@pugi/cli 0.1.0-alpha.10

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -0,0 +1,329 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { hashContent } from '../../core/file-cache.js';
5
+ import { recordFileMutation, recordToolCall, recordToolResult, } from '../../core/session.js';
6
+ /**
7
+ * `pugi undo` — revert the file mutations from the most recent successful
8
+ * `write` / `edit` / `multi_edit` tool result.
9
+ *
10
+ * Walk strategy:
11
+ * 1. Read `.pugi/events.jsonl` line by line into an array.
12
+ * 2. Walk backwards. Find the most recent `tool_result` whose
13
+ * `status === 'success'` and whose linked `tool_call` names a
14
+ * mutating tool (write / edit / multi_edit).
15
+ * 3. From that point, gather every `file_mutation` event that shares
16
+ * the same `toolCallId`. M1 records one `file_mutation` per
17
+ * mutating tool call, but the loop is shaped for the multi_edit
18
+ * future case where a single tool call mutates many files.
19
+ *
20
+ * Restore strategy (M1 — no blob store yet):
21
+ * For each mutation we restore from the workspace's git history when
22
+ * possible, with a strict safety gate to avoid silently overwriting
23
+ * the user's WORK-IN-PROGRESS:
24
+ *
25
+ * - `operation: create` → unlink the created file iff its current
26
+ * sha256 matches the recorded `afterHash`. Skip otherwise (the user
27
+ * has edited it since; refuse to delete their work).
28
+ * - `operation: update` → restore from `HEAD:<path>` iff (a) the file
29
+ * was tracked at HEAD AND (b) the HEAD content's sha256 matches the
30
+ * recorded `beforeHash`. Otherwise abort the turn (no partial
31
+ * reverts — the spec is atomic).
32
+ * - `operation: delete` → restore the deleted file from `HEAD:<path>`
33
+ * if tracked there; skip otherwise.
34
+ *
35
+ * If any single restore is unsafe the whole turn aborts with exit code
36
+ * 1 and no files are touched.
37
+ *
38
+ * Why git as the backing store: the file cache stores hash + metadata
39
+ * but NOT raw content (see `core/file-cache.ts`). M1 ships without a
40
+ * dedicated blob CAS; `git show HEAD:<path>` is the cheapest authoritative
41
+ * source for pre-mutation content as long as the workspace is a git
42
+ * repository. The α6.4 SQLite session store will replace this with a
43
+ * proper before-blob ledger.
44
+ */
45
+ const MUTATING_TOOLS = new Set(['write', 'edit', 'multi_edit']);
46
+ export async function runUndoCommand(_args, ctx) {
47
+ const eventsPath = resolve(ctx.workspaceRoot, '.pugi/events.jsonl');
48
+ if (!existsSync(eventsPath)) {
49
+ ctx.writeOutput({ command: 'undo', status: 'noop', reason: 'no_session' }, 'No session events found. Nothing to undo.');
50
+ return;
51
+ }
52
+ const events = parseEvents(eventsPath);
53
+ const target = findLastMutationTurn(events);
54
+ if (!target) {
55
+ ctx.writeOutput({ command: 'undo', status: 'noop', reason: 'no_mutation' }, 'No mutating tool result found in the session log.');
56
+ return;
57
+ }
58
+ if (target.mutations.length === 0) {
59
+ ctx.writeOutput({
60
+ command: 'undo',
61
+ status: 'noop',
62
+ reason: 'no_file_mutations',
63
+ toolCallId: target.toolCallId,
64
+ }, `Tool call ${target.toolCallId} recorded no file mutations.`);
65
+ return;
66
+ }
67
+ // Pre-flight: confirm every mutation is reversible before touching
68
+ // disk. This keeps the operation atomic per spec.
69
+ const plan = planReverts(ctx.workspaceRoot, target.mutations);
70
+ if (plan.aborted) {
71
+ ctx.writeOutput({
72
+ command: 'undo',
73
+ status: 'aborted',
74
+ reason: plan.reason,
75
+ toolCallId: target.toolCallId,
76
+ unsafe: plan.unsafe,
77
+ }, `Refusing to undo ${target.toolCallId}: ${plan.reason}`);
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ const restored = [];
82
+ for (const step of plan.steps) {
83
+ try {
84
+ executeRevert(ctx.workspaceRoot, step);
85
+ restored.push({ path: step.path, operation: step.operation });
86
+ }
87
+ catch (error) {
88
+ // A revert failed after pre-flight said it was safe. Surface the
89
+ // error and bail out — the spec says no partial state on failure.
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ ctx.writeOutput({
92
+ command: 'undo',
93
+ status: 'failed',
94
+ reason: message,
95
+ toolCallId: target.toolCallId,
96
+ restored,
97
+ failedAt: step.path,
98
+ }, `Undo failed mid-flight on ${step.path}: ${message}`);
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ }
103
+ // Audit the inverse mutations so the event log explains the undo.
104
+ const toolCallId = recordToolCall(ctx.session, 'undo', `revert ${target.toolCallId}`);
105
+ for (const step of plan.steps) {
106
+ recordFileMutation(ctx.session, {
107
+ toolCallId,
108
+ path: step.path,
109
+ operation: step.operation === 'create' ? 'delete' : 'update',
110
+ beforeHash: step.beforeHash,
111
+ afterHash: step.afterHash,
112
+ });
113
+ }
114
+ recordToolResult(ctx.session, toolCallId, 'success', `Undid ${restored.length} mutation(s) from ${target.toolCallId}`);
115
+ ctx.writeOutput({
116
+ command: 'undo',
117
+ status: 'ok',
118
+ toolCallId: target.toolCallId,
119
+ restored,
120
+ }, [
121
+ `Undid ${restored.length} mutation(s) from ${target.toolCallId}:`,
122
+ ...restored.map((entry) => ` ${entry.operation.padEnd(7)} ${entry.path}`),
123
+ ].join('\n'));
124
+ }
125
+ function parseEvents(eventsPath) {
126
+ const raw = readFileSync(eventsPath, 'utf8');
127
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
128
+ const out = [];
129
+ for (const line of lines) {
130
+ try {
131
+ const parsed = JSON.parse(line);
132
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
133
+ out.push(parsed);
134
+ }
135
+ }
136
+ catch {
137
+ // Drop malformed lines silently — partial writes mid-shutdown can
138
+ // produce them and undo should still work for the rest.
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+ function findLastMutationTurn(events) {
144
+ const toolCalls = new Map();
145
+ const results = [];
146
+ for (let i = 0; i < events.length; i += 1) {
147
+ const event = events[i];
148
+ if (!event)
149
+ continue;
150
+ if (event.type === 'tool_call' && typeof event.id === 'string' && typeof event.tool === 'string') {
151
+ toolCalls.set(event.id, { id: event.id, tool: event.tool });
152
+ }
153
+ else if (event.type === 'tool_result' &&
154
+ typeof event.id === 'string' &&
155
+ typeof event.toolCallId === 'string' &&
156
+ (event.status === 'success' || event.status === 'error' || event.status === 'cancelled')) {
157
+ results.push({
158
+ id: event.id,
159
+ toolCallId: event.toolCallId,
160
+ status: event.status,
161
+ index: i,
162
+ });
163
+ }
164
+ }
165
+ for (let i = results.length - 1; i >= 0; i -= 1) {
166
+ const result = results[i];
167
+ if (result.status !== 'success')
168
+ continue;
169
+ const call = toolCalls.get(result.toolCallId);
170
+ if (!call)
171
+ continue;
172
+ if (!MUTATING_TOOLS.has(call.tool))
173
+ continue;
174
+ // Collect file_mutation events whose toolCallId matches. We do NOT
175
+ // window by event index: M1 emits the file_mutation alongside the
176
+ // tool_result, but a future iteration that emits them earlier
177
+ // (e.g. mid-stream for multi_edit) still maps correctly.
178
+ const mutations = [];
179
+ for (const event of events) {
180
+ if (event.type !== 'file_mutation')
181
+ continue;
182
+ if (event.toolCallId !== result.toolCallId)
183
+ continue;
184
+ if (typeof event.path !== 'string')
185
+ continue;
186
+ if (event.operation !== 'create' &&
187
+ event.operation !== 'update' &&
188
+ event.operation !== 'delete' &&
189
+ event.operation !== 'move') {
190
+ continue;
191
+ }
192
+ mutations.push({
193
+ toolCallId: result.toolCallId,
194
+ path: event.path,
195
+ operation: event.operation,
196
+ beforeHash: typeof event.beforeHash === 'string' ? event.beforeHash : undefined,
197
+ afterHash: typeof event.afterHash === 'string' ? event.afterHash : undefined,
198
+ });
199
+ }
200
+ return {
201
+ toolCallId: result.toolCallId,
202
+ resultIndex: result.index,
203
+ tool: call.tool,
204
+ mutations,
205
+ };
206
+ }
207
+ return null;
208
+ }
209
+ function planReverts(root, mutations) {
210
+ const steps = [];
211
+ const unsafe = [];
212
+ for (const mutation of mutations) {
213
+ const abs = resolve(root, mutation.path);
214
+ switch (mutation.operation) {
215
+ case 'create': {
216
+ if (!existsSync(abs)) {
217
+ // Already gone — nothing to undo for this entry.
218
+ continue;
219
+ }
220
+ const current = readFileSync(abs, 'utf8');
221
+ const currentHash = hashContent(current);
222
+ if (!mutation.afterHash || currentHash !== mutation.afterHash) {
223
+ unsafe.push(`${mutation.path}: created by Pugi, then modified by the user — refusing to delete uncommitted work`);
224
+ continue;
225
+ }
226
+ steps.push({
227
+ path: mutation.path,
228
+ operation: 'create',
229
+ beforeHash: mutation.afterHash,
230
+ afterHash: undefined,
231
+ });
232
+ break;
233
+ }
234
+ case 'update': {
235
+ if (!existsSync(abs)) {
236
+ unsafe.push(`${mutation.path}: file expected to exist for update revert, not found`);
237
+ continue;
238
+ }
239
+ const current = readFileSync(abs, 'utf8');
240
+ const currentHash = hashContent(current);
241
+ if (!mutation.afterHash || currentHash !== mutation.afterHash) {
242
+ unsafe.push(`${mutation.path}: updated by Pugi, then modified by the user — refusing to overwrite uncommitted work`);
243
+ continue;
244
+ }
245
+ const headContent = readHead(root, mutation.path);
246
+ if (headContent === null) {
247
+ unsafe.push(`${mutation.path}: no git HEAD version available for revert`);
248
+ continue;
249
+ }
250
+ if (mutation.beforeHash && hashContent(headContent) !== mutation.beforeHash) {
251
+ unsafe.push(`${mutation.path}: git HEAD differs from the pre-mutation hash — refusing to restore an unverifiable version`);
252
+ continue;
253
+ }
254
+ steps.push({
255
+ path: mutation.path,
256
+ operation: 'update',
257
+ beforeHash: mutation.afterHash,
258
+ afterHash: mutation.beforeHash,
259
+ restoreContent: headContent,
260
+ });
261
+ break;
262
+ }
263
+ case 'delete': {
264
+ const headContent = readHead(root, mutation.path);
265
+ if (headContent === null) {
266
+ unsafe.push(`${mutation.path}: deleted file has no git HEAD version, cannot restore`);
267
+ continue;
268
+ }
269
+ if (mutation.beforeHash && hashContent(headContent) !== mutation.beforeHash) {
270
+ unsafe.push(`${mutation.path}: git HEAD differs from the pre-deletion hash, cannot restore safely`);
271
+ continue;
272
+ }
273
+ steps.push({
274
+ path: mutation.path,
275
+ operation: 'delete',
276
+ beforeHash: undefined,
277
+ afterHash: mutation.beforeHash,
278
+ restoreContent: headContent,
279
+ });
280
+ break;
281
+ }
282
+ case 'move':
283
+ // Move reverts are not in M1 scope — the tool layer does not yet
284
+ // emit `move` mutations. Flag as unsafe so a future operator who
285
+ // hits this gets a clear failure instead of partial revert.
286
+ unsafe.push(`${mutation.path}: move undo is not supported in M1`);
287
+ break;
288
+ }
289
+ }
290
+ if (unsafe.length > 0) {
291
+ return {
292
+ aborted: true,
293
+ reason: 'one or more files are unsafe to revert',
294
+ unsafe,
295
+ steps: [],
296
+ };
297
+ }
298
+ return { aborted: false, steps };
299
+ }
300
+ function executeRevert(root, step) {
301
+ const abs = resolve(root, step.path);
302
+ if (step.operation === 'create') {
303
+ // We previously created the file. Reverting means deleting it.
304
+ unlinkSync(abs);
305
+ return;
306
+ }
307
+ if (step.restoreContent === undefined) {
308
+ throw new Error(`internal: restoreContent missing for ${step.path}`);
309
+ }
310
+ // Atomic write via tmp+rename — same pattern as file-tools.writeTool.
311
+ const tmp = `${abs}.pugi-undo-${Date.now()}`;
312
+ writeFileSync(tmp, step.restoreContent, { encoding: 'utf8', mode: 0o600 });
313
+ renameSync(tmp, abs);
314
+ }
315
+ function readHead(root, path) {
316
+ try {
317
+ const out = execFileSync('git', ['show', `HEAD:${path}`], {
318
+ cwd: root,
319
+ encoding: 'utf8',
320
+ stdio: ['ignore', 'pipe', 'ignore'],
321
+ maxBuffer: 64 * 1024 * 1024,
322
+ });
323
+ return out;
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ //# sourceMappingURL=undo.js.map
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Update check + install-method detection — Sprint α6.2.
3
+ *
4
+ * Shows the REPL operator a one-shot banner at startup when the npm
5
+ * registry advertises a `@pugi/cli` version newer than what is running.
6
+ * The banner adapts the upgrade hint per install method so the operator
7
+ * sees a copy-pasteable command for their toolchain (brew / npm / curl).
8
+ *
9
+ * Constraints baked into the spec:
10
+ *
11
+ * - **Default-quiet.** Network errors, cache misses, and the
12
+ * `PUGI_SKIP_UPDATE_BANNER=1` env var all return `null` so the
13
+ * REPL renders unchanged.
14
+ * - **24h cache.** The check runs at most once per day, persisted to
15
+ * `~/.pugi/update-check.json`. Repeated REPL launches in the same
16
+ * day skip the registry call entirely.
17
+ * - **3s timeout.** A slow registry never blocks REPL startup; the
18
+ * undici request is aborted after 3 seconds and treated as a
19
+ * silent miss.
20
+ * - **Pure helpers, IO at the edge.** Detection, comparison, cache
21
+ * decode/encode are exported as pure functions with explicit env /
22
+ * home / now / fetch seams so the spec's 8 tests can drive every
23
+ * branch without touching the network or the real filesystem.
24
+ */
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { resolve } from 'node:path';
28
+ import { request } from 'undici';
29
+ const REGISTRY_URL = 'https://registry.npmjs.org/@pugi/cli/latest';
30
+ const FETCH_TIMEOUT_MS = 3_000;
31
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
32
+ /**
33
+ * Pin the install toolchain from the binary path + a small set of env
34
+ * markers. Order matters: the curl installer sets PUGI_VIA_CURL_INSTALL
35
+ * unconditionally, so we honor it first; brew is path-based; npm is the
36
+ * fallback for anything that looks like a node_modules layout; otherwise
37
+ * unknown.
38
+ */
39
+ export function detectInstallMethod(input = {}) {
40
+ const env = input.env ?? process.env;
41
+ const execPath = input.execPath ?? process.execPath;
42
+ if (env.PUGI_VIA_CURL_INSTALL === '1')
43
+ return 'curl';
44
+ // Homebrew on macOS keeps node + bin shims under /opt/homebrew (Apple
45
+ // silicon) or /usr/local/Cellar (Intel). Either suffices to pin brew.
46
+ if (execPath.includes('/opt/homebrew/') ||
47
+ execPath.includes('/usr/local/Cellar/') ||
48
+ execPath.includes('/homebrew/')) {
49
+ return 'brew';
50
+ }
51
+ // npm-global layouts: nvm, fnm, asdf, volta, yarn-global, and the
52
+ // classic `~/.npm-packages` PATH prefix all leave a fingerprint on
53
+ // execPath or PATH. Treat any of these as `npm` so the banner shows
54
+ // the matching `npm install -g` hint.
55
+ const pathEntries = (env.PATH ?? '').split(':');
56
+ const npmFingerprints = [
57
+ '/.nvm/',
58
+ '/.fnm/',
59
+ '/.volta/',
60
+ '/.asdf/',
61
+ '/.npm-packages/',
62
+ '/.config/yarn/global',
63
+ ];
64
+ if (npmFingerprints.some((marker) => execPath.includes(marker)))
65
+ return 'npm';
66
+ if (pathEntries.some((entry) => entry.includes('/.npm-packages') || entry.includes('/.config/yarn/global'))) {
67
+ return 'npm';
68
+ }
69
+ if (execPath.includes('/node_modules/'))
70
+ return 'npm';
71
+ return 'unknown';
72
+ }
73
+ /**
74
+ * Cache path resolver. PUGI_HOME wins (test seam matches the rest of
75
+ * the codebase), else `~/.pugi/update-check.json`.
76
+ */
77
+ export function resolveCachePath(home, env = process.env) {
78
+ const root = env.PUGI_HOME ?? resolve(home ?? homedir(), '.pugi');
79
+ return resolve(root, 'update-check.json');
80
+ }
81
+ export function loadCache(home, env = process.env) {
82
+ const path = resolveCachePath(home, env);
83
+ if (!existsSync(path))
84
+ return null;
85
+ try {
86
+ const text = readFileSync(path, 'utf8');
87
+ const parsed = JSON.parse(text);
88
+ if (typeof parsed.checkedAt !== 'string' || typeof parsed.latestVersion !== 'string') {
89
+ return null;
90
+ }
91
+ return { checkedAt: parsed.checkedAt, latestVersion: parsed.latestVersion };
92
+ }
93
+ catch {
94
+ // Corrupt or unreadable cache — treat as no cache. The next write
95
+ // overwrites the file cleanly.
96
+ return null;
97
+ }
98
+ }
99
+ export function saveCache(record, home, env = process.env) {
100
+ const path = resolveCachePath(home, env);
101
+ const dir = path.slice(0, path.lastIndexOf('/'));
102
+ try {
103
+ if (!existsSync(dir))
104
+ mkdirSync(dir, { recursive: true });
105
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8' });
106
+ }
107
+ catch {
108
+ // Best-effort. A read-only home or full disk should not crash REPL
109
+ // startup — we just skip caching this round.
110
+ }
111
+ }
112
+ export function isCacheFresh(record, nowMs) {
113
+ const checkedMs = Date.parse(record.checkedAt);
114
+ if (!Number.isFinite(checkedMs))
115
+ return false;
116
+ return nowMs - checkedMs < CACHE_TTL_MS;
117
+ }
118
+ /**
119
+ * Compare two semver strings (with prerelease support sufficient for
120
+ * `0.1.0-alpha.6` vs `0.1.0-alpha.7` / `0.1.0-beta.1` / `1.0.0`).
121
+ *
122
+ * Returns:
123
+ * -1 if `a < b`
124
+ * 0 if `a === b`
125
+ * 1 if `a > b`
126
+ *
127
+ * Rules follow semver §11:
128
+ * - Major.Minor.Patch compared numerically left-to-right.
129
+ * - A version with a prerelease tag is LOWER than the same version
130
+ * without one (`1.0.0-alpha` < `1.0.0`).
131
+ * - Prerelease identifiers compared dot-by-dot; numeric chunks
132
+ * numerically, mixed chunks ASCII-lexicographic.
133
+ */
134
+ export function compareVersions(a, b) {
135
+ const [coreA, preA] = splitVersion(a);
136
+ const [coreB, preB] = splitVersion(b);
137
+ for (let i = 0; i < 3; i += 1) {
138
+ const da = coreA[i] ?? 0;
139
+ const db = coreB[i] ?? 0;
140
+ if (da < db)
141
+ return -1;
142
+ if (da > db)
143
+ return 1;
144
+ }
145
+ if (preA === null && preB === null)
146
+ return 0;
147
+ if (preA === null)
148
+ return 1;
149
+ if (preB === null)
150
+ return -1;
151
+ return comparePrerelease(preA, preB);
152
+ }
153
+ function splitVersion(v) {
154
+ const dashIdx = v.indexOf('-');
155
+ const coreStr = dashIdx === -1 ? v : v.slice(0, dashIdx);
156
+ const preStr = dashIdx === -1 ? null : v.slice(dashIdx + 1);
157
+ const core = coreStr.split('.').map((n) => {
158
+ const parsed = Number.parseInt(n, 10);
159
+ return Number.isFinite(parsed) ? parsed : 0;
160
+ });
161
+ return [core, preStr];
162
+ }
163
+ function comparePrerelease(a, b) {
164
+ const partsA = a.split('.');
165
+ const partsB = b.split('.');
166
+ const max = Math.max(partsA.length, partsB.length);
167
+ for (let i = 0; i < max; i += 1) {
168
+ const pa = partsA[i];
169
+ const pb = partsB[i];
170
+ if (pa === undefined)
171
+ return -1;
172
+ if (pb === undefined)
173
+ return 1;
174
+ const na = Number.parseInt(pa, 10);
175
+ const nb = Number.parseInt(pb, 10);
176
+ const aIsNum = String(na) === pa;
177
+ const bIsNum = String(nb) === pb;
178
+ if (aIsNum && bIsNum) {
179
+ if (na < nb)
180
+ return -1;
181
+ if (na > nb)
182
+ return 1;
183
+ continue;
184
+ }
185
+ if (aIsNum)
186
+ return -1; // numeric < alphanumeric, per semver §11
187
+ if (bIsNum)
188
+ return 1;
189
+ if (pa < pb)
190
+ return -1;
191
+ if (pa > pb)
192
+ return 1;
193
+ }
194
+ return 0;
195
+ }
196
+ /**
197
+ * One-shot registry GET. Wrapped in a 3s timeout and silently swallows
198
+ * every failure mode — the spec says cache miss / network error = no
199
+ * banner. Returns the `version` string from npm's well-known
200
+ * `/:pkg/latest` document on success, or `null` on any failure.
201
+ */
202
+ export async function fetchLatestVersion(fetcher = request) {
203
+ const controller = new AbortController();
204
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
205
+ try {
206
+ const response = await fetcher(REGISTRY_URL, {
207
+ method: 'GET',
208
+ headers: { accept: 'application/json' },
209
+ bodyTimeout: FETCH_TIMEOUT_MS,
210
+ headersTimeout: FETCH_TIMEOUT_MS,
211
+ signal: controller.signal,
212
+ });
213
+ if (response.statusCode < 200 || response.statusCode >= 300) {
214
+ await response.body.dump();
215
+ return null;
216
+ }
217
+ const text = await response.body.text();
218
+ const parsed = JSON.parse(text);
219
+ if (typeof parsed.version !== 'string' || parsed.version.length === 0)
220
+ return null;
221
+ return parsed.version;
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ finally {
227
+ clearTimeout(timer);
228
+ }
229
+ }
230
+ /**
231
+ * High-level orchestrator wired into the CLI startup path. Returns the
232
+ * banner payload when an update is available AND every silence rule
233
+ * declines to fire, otherwise `null`.
234
+ *
235
+ * Silence rules (any one of these short-circuits to `null`):
236
+ * - `cliSkip === true` (operator passed `--no-update-check`).
237
+ * - `env.PUGI_SKIP_UPDATE_BANNER === '1'`.
238
+ * - `isTty === false` (CI / piped / scripted invocation).
239
+ * - Cache + network both miss — silent skip per spec.
240
+ * - `installed >= latest` — no upgrade to advertise.
241
+ */
242
+ export async function checkForUpdate(options) {
243
+ const env = options.env ?? process.env;
244
+ const now = options.now ?? Date.now;
245
+ const cliSkip = options.cliSkip === true;
246
+ const envSkip = env.PUGI_SKIP_UPDATE_BANNER === '1';
247
+ const isTty = options.isTty ??
248
+ (Boolean(process.stdout.isTTY) &&
249
+ Boolean(process.stdin.isTTY));
250
+ if (cliSkip || envSkip || !isTty)
251
+ return null;
252
+ // Cache-first path. A fresh cache (<24h) bypasses the registry round
253
+ // trip entirely so a daily REPL operator pays one network call per
254
+ // calendar day.
255
+ const cached = loadCache(options.home, env);
256
+ let latest = null;
257
+ if (cached && isCacheFresh(cached, now())) {
258
+ latest = cached.latestVersion;
259
+ }
260
+ else {
261
+ latest = await fetchLatestVersion(options.fetcher);
262
+ if (latest) {
263
+ saveCache({ checkedAt: new Date(now()).toISOString(), latestVersion: latest }, options.home, env);
264
+ }
265
+ }
266
+ if (!latest)
267
+ return null;
268
+ if (compareVersions(options.installed, latest) >= 0)
269
+ return null;
270
+ return {
271
+ installed: options.installed,
272
+ latest,
273
+ method: detectInstallMethod({ env }),
274
+ };
275
+ }
276
+ /**
277
+ * Render the per-method upgrade command. Pure helper so the Ink
278
+ * banner component and any future JSON / log surface share one source
279
+ * of truth on the copy.
280
+ */
281
+ export function upgradeCommand(method) {
282
+ switch (method) {
283
+ case 'brew':
284
+ return 'brew upgrade pugi-io/tap/pugi';
285
+ case 'npm':
286
+ return 'npm install -g @pugi/cli@latest';
287
+ case 'curl':
288
+ return 'curl -fsSL https://install.pugi.io | sh';
289
+ case 'unknown':
290
+ default:
291
+ return 'npm install -g @pugi/cli@latest # or your install method';
292
+ }
293
+ }
294
+ //# sourceMappingURL=update-check.js.map