@pugi/cli 0.1.0-alpha.6 → 0.1.0-alpha.7

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,128 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Open `url` in the default browser. Returns `{ opened: true }` on a
4
+ * successful spawn, `{ opened: false }` on any failure. The url is not
5
+ * shell-escaped here; we always pass it as a single argv element to a
6
+ * direct spawn (no shell), so quoting is irrelevant.
7
+ */
8
+ export async function autoOpenBrowser(url, deps = {}) {
9
+ const platform = deps.platform ?? process.platform;
10
+ const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
11
+ // Refuse anything that does not parse as http(s). The device-flow URL
12
+ // is always https — guarding here keeps a hostile server response
13
+ // from convincing us to spawn `open file:///etc/passwd` or worse.
14
+ if (!isSafeHttpUrl(url)) {
15
+ return { opened: false };
16
+ }
17
+ if (platform === 'darwin') {
18
+ return { opened: spawnDetached('open', [url]) };
19
+ }
20
+ if (platform === 'win32') {
21
+ // P1-3 (triple-review 2026-05-24): cmd.exe parses `&` as a command
22
+ // separator BEFORE Node hands argv to the child, regardless of
23
+ // `shell: false`. A device-flow URL like
24
+ // `https://app.pugi.io/devices/authorize?user_code=ABC&trace=xyz`
25
+ // would be truncated at the `&`, opening only the first half. We
26
+ // prefer PowerShell (its argv parser is sane: a single quoted URL
27
+ // round-trips verbatim) and fall back to a double-quoted cmd
28
+ // invocation when PowerShell is missing from PATH.
29
+ if (spawnDetached('powershell', [
30
+ '-NoProfile',
31
+ '-NonInteractive',
32
+ '-Command',
33
+ 'Start-Process',
34
+ quoteForPowerShell(url),
35
+ ])) {
36
+ return { opened: true };
37
+ }
38
+ // Fallback: `cmd /c start "" "<url>"`. The URL itself is wrapped
39
+ // in double quotes so cmd does not split on `&`; any embedded `"`
40
+ // in the URL is escaped using cmd's caret-quote convention.
41
+ return {
42
+ opened: spawnDetached('cmd', ['/c', 'start', '""', quoteForCmd(url)]),
43
+ };
44
+ }
45
+ if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
46
+ // Try xdg-open first (the freedesktop standard), then gio (GNOME),
47
+ // then a couple of well-known browsers as a last resort. Each
48
+ // attempt is a fresh spawn — if the binary is missing the helper
49
+ // returns false and we fall through.
50
+ for (const cmd of LINUX_OPENERS) {
51
+ if (spawnDetached(cmd, [url]))
52
+ return { opened: true };
53
+ }
54
+ return { opened: false };
55
+ }
56
+ // Unknown platform (android, aix, sunos…) — degrade gracefully.
57
+ return { opened: false };
58
+ }
59
+ const LINUX_OPENERS = ['xdg-open', 'gio', 'gnome-open', 'kde-open'];
60
+ function isSafeHttpUrl(candidate) {
61
+ try {
62
+ const url = new URL(candidate);
63
+ return url.protocol === 'https:' || url.protocol === 'http:';
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /**
70
+ * P1-3 (triple-review 2026-05-24): wrap a URL for PowerShell's
71
+ * `Start-Process` invocation. PowerShell's single-quote string literal
72
+ * does NOT process escape sequences; the only metachar inside is the
73
+ * single quote itself (escaped by doubling). URLs do not contain
74
+ * single quotes in practice, but the helper handles them defensively
75
+ * so future input shapes do not break.
76
+ */
77
+ function quoteForPowerShell(url) {
78
+ return `'${url.replace(/'/g, "''")}'`;
79
+ }
80
+ /**
81
+ * P1-3 (triple-review 2026-05-24): wrap a URL for cmd.exe's
82
+ * `start ""` invocation. Double quotes pin the URL as a single token
83
+ * so cmd does NOT split on `&`, `|`, `^`, `<`, `>`. Embedded `"` (rare
84
+ * in URLs but defensible) is escaped via cmd's caret-quote convention:
85
+ * `^"`.
86
+ */
87
+ function quoteForCmd(url) {
88
+ return `"${url.replace(/"/g, '^"')}"`;
89
+ }
90
+ /**
91
+ * Real spawn implementation. Detaches the child so the Pugi CLI can
92
+ * exit (or keep polling) without inheriting the browser's lifecycle.
93
+ * Returns `true` only when the spawn produced a pid; any error event
94
+ * (ENOENT for a missing binary, EACCES for a sandboxed permission
95
+ * denial) flips the result to `false`.
96
+ */
97
+ function defaultSpawnDetached(cmd, args) {
98
+ try {
99
+ const options = {
100
+ detached: true,
101
+ stdio: 'ignore',
102
+ shell: false,
103
+ };
104
+ const child = spawn(cmd, args.slice(), options);
105
+ // P3 polish (triple-review 2026-05-24): swallow the async `error`
106
+ // event (ENOENT for a missing binary, EACCES for a sandboxed
107
+ // permission denial). The old `errored` flag was always false at
108
+ // the synchronous `return !errored` point (the `error` event is
109
+ // delivered later in the next tick), so the flag was dead. The
110
+ // listener is still required: without it, Node escalates the
111
+ // event to an unhandled-error and crashes the host process when
112
+ // the binary is missing.
113
+ child.on('error', () => undefined);
114
+ if (typeof child.pid !== 'number')
115
+ return false;
116
+ // unref so the parent event loop is not held open by the browser
117
+ // handle. The browser process continues independently.
118
+ child.unref();
119
+ // Callers treat `true` as best-effort: the fallback "Browser
120
+ // didn't open?" hint is always rendered, so a silent spawn
121
+ // failure still leaves the user a usable path.
122
+ return true;
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ //# sourceMappingURL=auto-open-browser.js.map
@@ -0,0 +1,70 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Write `text` to the platform clipboard. Best-effort, async.
4
+ */
5
+ export async function writeClipboard(text, deps = {}) {
6
+ const platform = deps.platform ?? process.platform;
7
+ const spawnWrite = deps.spawnWrite ?? defaultSpawnWrite;
8
+ if (platform === 'darwin') {
9
+ const ok = await spawnWrite('pbcopy', [], text);
10
+ return { copied: ok };
11
+ }
12
+ if (platform === 'win32') {
13
+ // clip.exe is shipped with every Windows >= XP. UTF-16LE would be
14
+ // ideal, but a UTF-8 BOM-less write covers the ASCII subset that
15
+ // device-flow URLs use (no non-ASCII characters reach this path).
16
+ const ok = await spawnWrite('clip', [], text);
17
+ return { copied: ok };
18
+ }
19
+ if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
20
+ // Wayland-native sessions usually have wl-copy and not xclip. Try
21
+ // wl-copy first if WAYLAND_DISPLAY is set, otherwise prefer xclip.
22
+ const order = process.env.WAYLAND_DISPLAY
23
+ ? ['wl-copy', 'xclip', 'xsel']
24
+ : ['xclip', 'wl-copy', 'xsel'];
25
+ for (const tool of order) {
26
+ const args = tool === 'xclip' ? ['-selection', 'clipboard'] : tool === 'xsel' ? ['--clipboard', '--input'] : [];
27
+ const ok = await spawnWrite(tool, args, text);
28
+ if (ok)
29
+ return { copied: true };
30
+ }
31
+ return { copied: false };
32
+ }
33
+ return { copied: false };
34
+ }
35
+ /**
36
+ * Real spawn. Writes `text` to the child's stdin and resolves with
37
+ * `true` only when the child exits 0. Errors (missing binary,
38
+ * permission denied, non-zero exit) flip the result to `false`.
39
+ */
40
+ function defaultSpawnWrite(cmd, args, text) {
41
+ return new Promise((resolve) => {
42
+ let settled = false;
43
+ const settle = (value) => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ resolve(value);
48
+ };
49
+ try {
50
+ const options = {
51
+ stdio: ['pipe', 'ignore', 'ignore'],
52
+ shell: false,
53
+ };
54
+ const child = spawn(cmd, args.slice(), options);
55
+ child.on('error', () => settle(false));
56
+ child.on('close', (code) => settle(code === 0));
57
+ const stdin = child.stdin;
58
+ if (!stdin) {
59
+ settle(false);
60
+ return;
61
+ }
62
+ stdin.on('error', () => settle(false));
63
+ stdin.end(text, 'utf8');
64
+ }
65
+ catch {
66
+ settle(false);
67
+ }
68
+ });
69
+ }
70
+ //# sourceMappingURL=clipboard.js.map
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Best-effort clipboard READ helper - Sprint α6.14.
3
+ *
4
+ * Mirror of the clipboard WRITE helper but for the opposite
5
+ * direction. Powers Ctrl+V paste in the REPL input box: when the
6
+ * operator presses Ctrl+V we spawn the platform's paste binary
7
+ * (pbpaste / wl-paste / xclip -o / PowerShell Get-Clipboard) and
8
+ * insert the result at the cursor.
9
+ *
10
+ * Contract:
11
+ * - Returns `{ text }` when the helper exited 0 with non-empty
12
+ * stdout. Trailing single newline is stripped (clipboard
13
+ * contents authored by the operator rarely include the trailing
14
+ * LF the paste helpers append).
15
+ * - Returns `{ text: null }` on any failure: missing binary,
16
+ * non-zero exit, no $DISPLAY on headless Linux, EAGAIN.
17
+ * - Never throws. Caller falls back to "Ctrl+V not available -
18
+ * try right-click paste" hint.
19
+ * - Targets node >= 20.
20
+ *
21
+ * Implementation note: we do NOT optimistically run all three Linux
22
+ * helpers in parallel - the cost of spawning xclip when wl-copy
23
+ * already produced text is small but visible (50ms+ each on cold
24
+ * cache). Ordering: WAYLAND_DISPLAY → wl-paste; otherwise xclip first.
25
+ */
26
+ import { spawn } from 'node:child_process';
27
+ export async function readClipboard(deps = {}) {
28
+ const platform = deps.platform ?? process.platform;
29
+ const spawnRead = deps.spawnRead ?? defaultSpawnRead;
30
+ const env = deps.env ?? process.env;
31
+ if (platform === 'darwin') {
32
+ const text = await spawnRead('pbpaste', []);
33
+ return { text: normalise(text) };
34
+ }
35
+ if (platform === 'win32') {
36
+ // PowerShell is the universally available path. Get-Clipboard
37
+ // emits UTF-16 by default; the -Raw flag preserves newlines and
38
+ // returns one string.
39
+ const text = await spawnRead('powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw']);
40
+ return { text: normalise(text) };
41
+ }
42
+ if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
43
+ const order = env.WAYLAND_DISPLAY
44
+ ? ['wl-paste', 'xclip', 'xsel']
45
+ : ['xclip', 'wl-paste', 'xsel'];
46
+ for (const tool of order) {
47
+ const args = tool === 'xclip'
48
+ ? ['-selection', 'clipboard', '-o']
49
+ : tool === 'xsel'
50
+ ? ['--clipboard', '--output']
51
+ : ['--no-newline'];
52
+ const text = await spawnRead(tool, args);
53
+ if (text !== null && text.length > 0) {
54
+ return { text: normalise(text) };
55
+ }
56
+ }
57
+ return { text: null };
58
+ }
59
+ return { text: null };
60
+ }
61
+ function normalise(raw) {
62
+ if (raw === null)
63
+ return null;
64
+ if (raw.length === 0)
65
+ return null;
66
+ // Strip a single trailing LF the helpers often append. Multi-line
67
+ // pastes preserve interior newlines.
68
+ return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
69
+ }
70
+ /**
71
+ * Hard cap on clipboard payload size. The paste helper streams stdout
72
+ * with no upper bound by default, so a pathologically large clipboard
73
+ * (e.g. a hostile actor's 500 MiB file URL list, or an accidental copy
74
+ * of a large binary) would OOM the CLI process if we kept appending.
75
+ * 1 MiB is plenty for any realistic prompt + code snippet paste and
76
+ * still leaves room in V8's default young-generation heap.
77
+ */
78
+ const MAX_CLIPBOARD_BYTES = 1024 * 1024; // 1 MiB
79
+ function defaultSpawnRead(cmd, args) {
80
+ return new Promise((resolve) => {
81
+ let settled = false;
82
+ let overflow = false;
83
+ let totalBytes = 0;
84
+ const chunks = [];
85
+ const settle = (value) => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ resolve(value);
90
+ };
91
+ try {
92
+ const options = {
93
+ stdio: ['ignore', 'pipe', 'ignore'],
94
+ shell: false,
95
+ };
96
+ const child = spawn(cmd, args.slice(), options);
97
+ child.on('error', () => settle(null));
98
+ child.stdout?.on('data', (b) => {
99
+ if (overflow)
100
+ return;
101
+ totalBytes += b.length;
102
+ if (totalBytes > MAX_CLIPBOARD_BYTES) {
103
+ overflow = true;
104
+ try {
105
+ child.kill('SIGTERM');
106
+ }
107
+ catch {
108
+ // Best effort; close handler still settles below.
109
+ }
110
+ return;
111
+ }
112
+ chunks.push(b);
113
+ });
114
+ child.on('close', (code) => {
115
+ if (overflow)
116
+ return settle(null);
117
+ if (code !== 0)
118
+ return settle(null);
119
+ const buf = Buffer.concat(chunks);
120
+ settle(buf.toString('utf8'));
121
+ });
122
+ }
123
+ catch {
124
+ settle(null);
125
+ }
126
+ });
127
+ }
128
+ /* ------------------------------------------------------------------ */
129
+ /* Bracketed-paste mode helpers */
130
+ /* ------------------------------------------------------------------ */
131
+ /**
132
+ * Modern terminals (iTerm2, Alacritty, GNOME Terminal, kitty,
133
+ * Windows Terminal) bracket pasted text with `ESC[200~ ... ESC[201~`
134
+ * when bracketed-paste mode is enabled. The input box can detect
135
+ * these markers and disable newline-as-submit during the paste burst
136
+ * so a multi-line paste does not fire `onSubmit` mid-paste.
137
+ *
138
+ * The constants here are exported so the input box and the unit
139
+ * test can share the byte sequences without re-implementing them.
140
+ */
141
+ export const PASTE_START = '\x1b[200~';
142
+ export const PASTE_END = '\x1b[201~';
143
+ export const CTRL_V = '\x16';
144
+ export function classifyChunk(chunk, inPaste) {
145
+ if (inPaste) {
146
+ const endIdx = chunk.indexOf(PASTE_END);
147
+ if (endIdx === -1) {
148
+ return { kind: 'paste-cont', textInPaste: chunk };
149
+ }
150
+ return {
151
+ kind: 'paste-end',
152
+ textInPaste: chunk.slice(0, endIdx),
153
+ textAfter: chunk.slice(endIdx + PASTE_END.length),
154
+ };
155
+ }
156
+ const startIdx = chunk.indexOf(PASTE_START);
157
+ if (startIdx === -1) {
158
+ return { kind: 'plain', text: chunk };
159
+ }
160
+ const afterStart = chunk.slice(startIdx + PASTE_START.length);
161
+ const endIdx = afterStart.indexOf(PASTE_END);
162
+ if (endIdx === -1) {
163
+ return {
164
+ kind: 'paste-start',
165
+ textBefore: chunk.slice(0, startIdx),
166
+ textInPaste: afterStart,
167
+ };
168
+ }
169
+ return {
170
+ kind: 'paste-only',
171
+ text: afterStart.slice(0, endIdx),
172
+ };
173
+ }
174
+ //# sourceMappingURL=clipboard-read.js.map
@@ -0,0 +1,175 @@
1
+ /**
2
+ * FZF-style history search - Sprint α6.14.
3
+ *
4
+ * Powers the Ctrl+R (reverse) / Ctrl+S (forward) interactive search
5
+ * mode in the REPL input box. Hand-rolled scorer (no dep) tuned for
6
+ * short query strings (operator typing a few chars) over up to
7
+ * MAX_HISTORY_ENTRIES candidates.
8
+ *
9
+ * Scoring model (mirrors fzf v2 mid-priorities, simplified):
10
+ *
11
+ * - Substring match required. Non-matching candidates score 0.
12
+ * - Prefix match: +50
13
+ * - Word-boundary start (' ', '/', '-', '_'): +20
14
+ * - Camel boundary (`abcD` → 'D'): +10
15
+ * - Contiguous run bonus: +5 per consecutive char after the first
16
+ * - Distance penalty: -1 per gap char between query characters
17
+ * - Recency bonus: +0.5 × (recencyRank / total) so newer entries
18
+ * break ties in the operator's favour
19
+ *
20
+ * The result is a list of matches ordered by descending score with
21
+ * the matched character positions preserved so the UI can highlight
22
+ * them. `cycle` steps the focused match index forward (Ctrl+R again)
23
+ * or backward (Ctrl+S) modulo the result length.
24
+ *
25
+ * Contract:
26
+ * - `searchHistory(query, entries, options)` is pure. No I/O.
27
+ * - Empty query returns ALL entries newest-first so the operator
28
+ * can browse without typing.
29
+ * - Matching is case-insensitive on ASCII; non-ASCII characters
30
+ * compare as-is.
31
+ * - `cycle(state, direction)` is a no-op when there are no matches.
32
+ */
33
+ const DEFAULT_LIMIT = 50;
34
+ const WORD_BOUNDARIES = new Set([' ', '/', '-', '_', '.', ',', ':', ';', '\t']);
35
+ /**
36
+ * Score one candidate against the query. Returns `null` when the
37
+ * query cannot be matched as an in-order substring (not necessarily
38
+ * contiguous). The matched positions are returned so the UI can
39
+ * underline them.
40
+ */
41
+ export function scoreCandidate(query, candidate) {
42
+ if (query.length === 0) {
43
+ return { score: 0, positions: [] };
44
+ }
45
+ const lowerQuery = query.toLowerCase();
46
+ const lowerCandidate = candidate.toLowerCase();
47
+ // Walk the candidate, greedily matching each query char in order.
48
+ const positions = [];
49
+ let qIdx = 0;
50
+ for (let i = 0; i < lowerCandidate.length && qIdx < lowerQuery.length; i += 1) {
51
+ if (lowerCandidate[i] === lowerQuery[qIdx]) {
52
+ positions.push(i);
53
+ qIdx += 1;
54
+ }
55
+ }
56
+ if (qIdx < lowerQuery.length)
57
+ return null;
58
+ // Score the match.
59
+ let score = 0;
60
+ // Prefix bonus.
61
+ if (positions[0] === 0)
62
+ score += 50;
63
+ // Substring contiguity + boundary bonuses.
64
+ let lastPos = -2;
65
+ for (let p = 0; p < positions.length; p += 1) {
66
+ const pos = positions[p];
67
+ // Contiguous run.
68
+ if (pos === lastPos + 1) {
69
+ score += 5;
70
+ }
71
+ else if (lastPos >= 0) {
72
+ // Distance penalty for non-contiguous matches.
73
+ score -= pos - lastPos - 1;
74
+ }
75
+ // Word-boundary start.
76
+ if (pos === 0) {
77
+ // Already counted as prefix.
78
+ }
79
+ else {
80
+ const prev = candidate[pos - 1] ?? '';
81
+ if (WORD_BOUNDARIES.has(prev)) {
82
+ score += 20;
83
+ }
84
+ else {
85
+ // Camel boundary: prev is lowercase, current is uppercase.
86
+ const cur = candidate[pos] ?? '';
87
+ if (prev === prev.toLowerCase() && cur !== cur.toLowerCase()) {
88
+ score += 10;
89
+ }
90
+ }
91
+ }
92
+ lastPos = pos;
93
+ }
94
+ return { score, positions };
95
+ }
96
+ /**
97
+ * Search a history list for matches. Entries are passed oldest-first
98
+ * (the format `history.read` returns); the recency bonus uses the
99
+ * original index so newer entries win ties.
100
+ */
101
+ export function searchHistory(query, entries, options = {}) {
102
+ const limit = options.limit ?? DEFAULT_LIMIT;
103
+ const total = entries.length;
104
+ if (query.length === 0) {
105
+ // Empty query - browse mode. Return newest-first up to limit, no
106
+ // scoring needed.
107
+ const out = [];
108
+ for (let i = total - 1; i >= 0 && out.length < limit; i -= 1) {
109
+ const brief = entries[i];
110
+ out.push({ brief, positions: [], score: 0, originalIndex: i });
111
+ }
112
+ return out;
113
+ }
114
+ const candidates = [];
115
+ for (let i = 0; i < total; i += 1) {
116
+ const brief = entries[i];
117
+ const scored = scoreCandidate(query, brief);
118
+ if (!scored)
119
+ continue;
120
+ // Recency bonus: 0..0.5 weight scaled by index/total.
121
+ const recency = total === 0 ? 0 : (i / total) * 0.5;
122
+ candidates.push({
123
+ brief,
124
+ positions: scored.positions,
125
+ score: scored.score + recency,
126
+ originalIndex: i,
127
+ });
128
+ }
129
+ candidates.sort((a, b) => {
130
+ if (b.score !== a.score)
131
+ return b.score - a.score;
132
+ // Tie-breaker: newer first.
133
+ return b.originalIndex - a.originalIndex;
134
+ });
135
+ return candidates.slice(0, limit);
136
+ }
137
+ /**
138
+ * Initial search state for a fresh Ctrl+R press. Empty query +
139
+ * focusedIndex 0 over the browse list.
140
+ */
141
+ export function initialSearchState(entries) {
142
+ const matches = searchHistory('', entries);
143
+ return { query: '', matches, focusedIndex: 0 };
144
+ }
145
+ /**
146
+ * Update the search state when the operator types a new query char or
147
+ * removes one. The focused index is clamped to the new match list so
148
+ * the UI never points off-the-end.
149
+ */
150
+ export function applyQuery(state, query, entries) {
151
+ const matches = searchHistory(query, entries);
152
+ const focusedIndex = matches.length === 0 ? 0 : Math.min(state.focusedIndex, matches.length - 1);
153
+ return { query, matches, focusedIndex };
154
+ }
155
+ /**
156
+ * Move the focused match. `+1` = next (Ctrl+R repeat), `-1` = previous
157
+ * (Ctrl+S). Wraps around modulo result length so the operator never
158
+ * has to chase the end.
159
+ */
160
+ export function cycle(state, direction) {
161
+ if (state.matches.length === 0)
162
+ return state;
163
+ const len = state.matches.length;
164
+ const next = (state.focusedIndex + direction + len) % len;
165
+ return { ...state, focusedIndex: next };
166
+ }
167
+ /**
168
+ * Pull the brief from the currently focused match, or `null` when
169
+ * the result list is empty (Enter accepts nothing).
170
+ */
171
+ export function currentBrief(state) {
172
+ const match = state.matches[state.focusedIndex];
173
+ return match ? match.brief : null;
174
+ }
175
+ //# sourceMappingURL=history-search.js.map