promptdock 1.0.1 → 1.2.0

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,113 @@
1
+ /**
2
+ * Key handling for the interactive picker, as a PURE reducer.
3
+ *
4
+ * Kept out of ui.ts on purpose: the rendering half needs a real TTY (raw mode,
5
+ * cursor moves) and cannot be unit-tested, but the DECISIONS — what wraps, what
6
+ * commits, what cancels — are the part that regresses. Pure in, pure out, so
7
+ * every key path is pinned by a table test with no terminal involved.
8
+ */
9
+ const wrap = (i, count) => ((i % count) + count) % count;
10
+ /**
11
+ * One keypress → the next state, or a terminal outcome.
12
+ *
13
+ * Both input styles stay live at once, which is the whole point: arrows move the
14
+ * highlight, digits ALSO move it (so typing is visible before you commit), and
15
+ * Enter commits whichever the user last expressed. Space commits the highlight
16
+ * only — it is a "this one" gesture, and treating it as a number-confirm would
17
+ * make a half-typed "1" + Space silently pick option 1 when the user meant 12.
18
+ *
19
+ * Digits never auto-commit. `3` in a 4-item list is unambiguous and a fancier
20
+ * picker would fire immediately, but the user asked to KEEP number entry, and
21
+ * number entry means "type it, then press Enter" — auto-firing would make a
22
+ * two-digit list behave differently from a one-digit list.
23
+ *
24
+ * `hotkeys` (letter → option index) commit IMMEDIATELY — the confirm path
25
+ * passes {y:0, n:1}. Without this, `n`+Enter at a default-Yes "Install?"
26
+ * committed the DEFAULT: the muscle-memory answer this CLI's own [y/N]
27
+ * fallback trains was silently ignored and the UI executed the opposite of
28
+ * what the user typed (dual-review find, PTY-proven).
29
+ */
30
+ export function reduceSelectKey(state, key, count, hotkeys) {
31
+ const name = key.name;
32
+ const seq = key.sequence;
33
+ const keep = (s) => ({ kind: "state", state: s });
34
+ // Raw mode swallows SIGINT, so Ctrl-C is ours to honour or the picker hangs.
35
+ // It carries the `signal` mark → exit 130; Esc/^D are deliberate cancels.
36
+ if (key.ctrl && name === "c")
37
+ return { kind: "cancel", signal: true };
38
+ if (key.ctrl && name === "d")
39
+ return { kind: "cancel" };
40
+ if (name === "escape")
41
+ return { kind: "cancel" };
42
+ // Letter hotkeys beat every other mapping except cancel — `n` must never be
43
+ // read as "ignored key" while the footer implies typing works.
44
+ if (name && !key.ctrl && !key.meta && hotkeys && hotkeys[name] !== undefined) {
45
+ const idx = hotkeys[name];
46
+ if (idx >= 0 && idx < count)
47
+ return { kind: "commit", index: idx };
48
+ }
49
+ if (name === "return" || name === "enter") {
50
+ if (state.buffer === "")
51
+ return { kind: "commit", index: state.index };
52
+ const n = Number(state.buffer);
53
+ if (Number.isInteger(n) && n >= 1 && n <= count)
54
+ return { kind: "commit", index: n - 1 };
55
+ return {
56
+ kind: "reject",
57
+ state: { index: state.index, buffer: "" },
58
+ message: `Enter a number between 1 and ${count}.`,
59
+ };
60
+ }
61
+ if (name === "space") {
62
+ // With a live digit buffer, Space means what Enter means — validate the
63
+ // number. Committing the stale highlight would discard the visible buffer
64
+ // ("Number: 12") for a row the user never named (review find).
65
+ if (state.buffer !== "") {
66
+ const n = Number(state.buffer);
67
+ if (Number.isInteger(n) && n >= 1 && n <= count)
68
+ return { kind: "commit", index: n - 1 };
69
+ return {
70
+ kind: "reject",
71
+ state: { index: state.index, buffer: "" },
72
+ message: `Enter a number between 1 and ${count}.`,
73
+ };
74
+ }
75
+ return { kind: "commit", index: state.index };
76
+ }
77
+ if (name === "up" || name === "k" || (key.ctrl && name === "p"))
78
+ return keep({ index: wrap(state.index - 1, count), buffer: "" });
79
+ if (name === "down" || name === "j" || (key.ctrl && name === "n"))
80
+ return keep({ index: wrap(state.index + 1, count), buffer: "" });
81
+ if (name === "home")
82
+ return keep({ index: 0, buffer: "" });
83
+ if (name === "end")
84
+ return keep({ index: count - 1, buffer: "" });
85
+ if (name === "backspace") {
86
+ const buffer = state.buffer.slice(0, -1);
87
+ const n = Number(buffer);
88
+ // Re-point at whatever the shortened buffer now names; keep the highlight if it names nothing.
89
+ const index = buffer !== "" && n >= 1 && n <= count ? n - 1 : state.index;
90
+ return keep({ index, buffer });
91
+ }
92
+ if (seq !== undefined && /^[0-9]$/.test(seq)) {
93
+ // Bounded: 3 digits names any option a 20-row picker can hold; an unbounded
94
+ // buffer + full-frame repaint per digit is quadratic under a digit-spam
95
+ // paste (codex find). Extra digits are ignored, never mis-parsed.
96
+ if (state.buffer.length >= 3)
97
+ return keep(state);
98
+ // Leading zeros are dropped so "0" then "3" is 3, not a dead buffer.
99
+ const buffer = (state.buffer + seq).replace(/^0+(?=\d)/, "");
100
+ const n = Number(buffer);
101
+ const index = n >= 1 && n <= count ? n - 1 : state.index;
102
+ return keep({ index, buffer });
103
+ }
104
+ return keep(state);
105
+ }
106
+ /** Footer hint. Mentions BOTH input styles — neither is discoverable otherwise.
107
+ * `hotkeyLabel` (e.g. "y/n") leads when the picker has letter hotkeys. */
108
+ export function selectHint(count, buffer, hotkeyLabel) {
109
+ if (buffer !== "")
110
+ return `Number: ${buffer} ↵ confirm · ⌫ clear · esc cancel`;
111
+ const hot = hotkeyLabel ? `${hotkeyLabel} · ` : "";
112
+ return `${hot}↑↓ move · ↵/space select · 1-${count} then ↵ · esc cancel`;
113
+ }
package/dist/ui.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CliIo } from "./context.js";
2
- /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color. */
2
+ /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
3
+ * TERM=dumb too — a terminal that can't render ANSI gets plain text. */
3
4
  export declare function colors(env: Record<string, string | undefined>, isTTY: boolean): {
4
5
  bold: (s: string) => string;
5
6
  dim: (s: string) => string;
@@ -14,9 +15,18 @@ export declare function formatCountdown(totalSecs: number): string;
14
15
  /** ~Nh retry copy for the cap verdict (mirrors the app's premium-cap copy). */
15
16
  export declare function retryHours(retryAfterSecs: number): string;
16
17
  /**
17
- * Numbered-list picker (TTY only callers gate). Returns the chosen index.
18
- * Enter accepts the preselected default.
18
+ * Picker. Arrow-key driven when the terminal can do raw mode, numbered otherwise.
19
+ * Returns the chosen index.
20
+ *
21
+ * BOTH input styles are live in the interactive path: ↑↓ (and j/k, ^p/^n) move,
22
+ * ↵/space select, and typing a number still works — it moves the highlight as you
23
+ * type and ↵ confirms it. The numbered path below is not a lesser fallback, it is
24
+ * the contract for every non-TTY caller (CI, pipes, tests) and its behaviour is
25
+ * unchanged: Enter alone accepts the preselect.
19
26
  */
20
- export declare function promptSelect(io: CliIo, title: string, options: string[], preselect: number): Promise<number>;
21
- /** y/N confirm (TTY only — callers gate; -y bypasses upstream). */
22
- export declare function confirm(io: CliIo, prompt: string, def?: boolean): Promise<boolean>;
27
+ export declare function promptSelect(io: CliIo, title: string, options: string[], preselect: number, c?: Colors, hotkeys?: Record<string, number>, hotkeyLabel?: string): Promise<number>;
28
+ /**
29
+ * Confirm. Arrow-driven Yes/No when the terminal allows, y/N text otherwise.
30
+ * (TTY only — callers gate; -y bypasses upstream.)
31
+ */
32
+ export declare function confirm(io: CliIo, prompt: string, def?: boolean, c?: Colors): Promise<boolean>;
package/dist/ui.js CHANGED
@@ -1,6 +1,9 @@
1
- /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color. */
1
+ import { CliError, EXIT } from "./errors.js";
2
+ import { reduceSelectKey, selectHint } from "./select-keys.js";
3
+ /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
4
+ * TERM=dumb too — a terminal that can't render ANSI gets plain text. */
2
5
  export function colors(env, isTTY) {
3
- const on = isTTY && !env.NO_COLOR;
6
+ const on = isTTY && !env.NO_COLOR && env.TERM !== "dumb";
4
7
  const wrap = (open, close) => (s) => (on ? `${open}${s}${close}` : s);
5
8
  return {
6
9
  bold: wrap("\u001b[1m", "\u001b[22m"),
@@ -28,11 +31,31 @@ export function formatCountdown(totalSecs) {
28
31
  export function retryHours(retryAfterSecs) {
29
32
  return `~${Math.max(1, Math.ceil(retryAfterSecs / 3600))}h`;
30
33
  }
34
+ const HIDE_CURSOR = "[?25l";
35
+ const SHOW_CURSOR = "[?25h";
36
+ /** Above this, cursor-up repainting fights terminal scrollback — use the numbered reader. */
37
+ const MAX_INTERACTIVE_ROWS = 20;
38
+ const passthrough = (s) => s;
31
39
  /**
32
- * Numbered-list picker (TTY only callers gate). Returns the chosen index.
33
- * Enter accepts the preselected default.
40
+ * Picker. Arrow-key driven when the terminal can do raw mode, numbered otherwise.
41
+ * Returns the chosen index.
42
+ *
43
+ * BOTH input styles are live in the interactive path: ↑↓ (and j/k, ^p/^n) move,
44
+ * ↵/space select, and typing a number still works — it moves the highlight as you
45
+ * type and ↵ confirms it. The numbered path below is not a lesser fallback, it is
46
+ * the contract for every non-TTY caller (CI, pipes, tests) and its behaviour is
47
+ * unchanged: Enter alone accepts the preselect.
34
48
  */
35
- export async function promptSelect(io, title, options, preselect) {
49
+ export async function promptSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel) {
50
+ // Height gate: the frame is title + options + hint. If it doesn't fit the
51
+ // terminal, `ESC[NA` clamps at the top edge and repaints mangle scrollback
52
+ // (an 8-row tmux pane was the review's concrete case) — the numbered reader
53
+ // is the honest fallback there.
54
+ const rows = io.size?.().rows ?? 24;
55
+ const fits = options.length + 2 <= rows - 1;
56
+ if (io.rawKeys && options.length > 0 && options.length <= MAX_INTERACTIVE_ROWS && fits) {
57
+ return interactiveSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel);
58
+ }
36
59
  io.out(title);
37
60
  options.forEach((opt, i) => {
38
61
  const marker = i === preselect ? "❯" : " ";
@@ -48,8 +71,136 @@ export async function promptSelect(io, title, options, preselect) {
48
71
  io.out(`Enter a number between 1 and ${options.length}.`);
49
72
  }
50
73
  }
51
- /** y/N confirm (TTY only callers gate; -y bypasses upstream). */
52
- export async function confirm(io, prompt, def = false) {
74
+ /** Clamp a PLAIN (uncolored) line to the terminal width. Load-bearing for the
75
+ * in-place repaint: `ESC[NA` moves PHYSICAL rows while `painted` counts
76
+ * logical lines, so one soft-wrapped line desyncs every later repaint into a
77
+ * trail of stale frames (3 voices + codex converged on this; the scope
78
+ * prompt's absolute paths wrap on a stock 80-col terminal). Clamping before
79
+ * colorization keeps logical lines == physical rows by construction. */
80
+ function clampLine(line, columns) {
81
+ const max = Math.max(8, columns - 1);
82
+ if (line.length <= max)
83
+ return line;
84
+ return line.slice(0, max - 1) + "…";
85
+ }
86
+ async function interactiveSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel) {
87
+ const count = options.length;
88
+ const bold = c?.bold ?? passthrough;
89
+ const cyan = c?.cyan ?? passthrough;
90
+ const dim = c?.dim ?? passthrough;
91
+ const width = String(count).length;
92
+ let state = { index: Math.min(Math.max(preselect, 0), count - 1), buffer: "" };
93
+ let notice = "";
94
+ let painted = 0;
95
+ const frame = () => {
96
+ // Re-read the width each paint so a mid-picker resize clamps the NEXT
97
+ // frame correctly (past frames are already committed to scrollback).
98
+ const columns = io.size?.().columns ?? 80;
99
+ const rows = options.map((opt, i) => {
100
+ const num = `${i + 1}.`.padStart(width + 1);
101
+ const body = clampLine(`${i === state.index ? "❯" : " "} ${num} ${opt}`, columns);
102
+ return i === state.index ? cyan(bold(body)) : body;
103
+ });
104
+ return [
105
+ clampLine(title, columns),
106
+ ...rows,
107
+ dim(clampLine(notice || selectHint(count, state.buffer, hotkeyLabel), columns)),
108
+ ];
109
+ };
110
+ const paint = () => {
111
+ // Redraw in place: jump back over the previous frame, then clear-and-rewrite
112
+ // each line. Clearing matters — a shorter line would otherwise leave the tail
113
+ // of the longer one it replaced (a stale path fragment) on screen.
114
+ const lines = frame();
115
+ let out = painted > 0 ? `[${painted}A` : "";
116
+ for (const line of lines)
117
+ out += `\r${line}\n`;
118
+ io.write(out);
119
+ painted = lines.length;
120
+ };
121
+ /** On commit, COLLAPSE the frame to one summary line ("Install? Yes") instead
122
+ * of leaving the whole option list + a stale hint in scrollback (review
123
+ * polish item). Clears every previously painted row, writes the summary,
124
+ * and parks the cursor directly under it so the CLI's next output flows on. */
125
+ const collapse = (chosen) => {
126
+ const columns = io.size?.().columns ?? 80;
127
+ const summary = clampLine(`${title} ${chosen}`, columns);
128
+ let out = painted > 0 ? `[${painted}A` : "";
129
+ out += `\r${cyan(summary)}\n`;
130
+ for (let i = 0; i < painted - 1; i++)
131
+ out += ``;
132
+ if (painted > 1)
133
+ out += `[${painted - 1}A\r`;
134
+ io.write(out);
135
+ painted = 1;
136
+ };
137
+ io.write(HIDE_CURSOR);
138
+ // Seeded with a no-op rather than null: the executor below runs synchronously so
139
+ // this is always replaced before the await, and a non-nullable release means the
140
+ // finally can never be skipped by a narrowing accident. Leaving raw mode on is
141
+ // the one failure here that wrecks the user's terminal after we exit.
142
+ let release = () => { };
143
+ try {
144
+ return await new Promise((resolve, reject) => {
145
+ paint();
146
+ // Settled guard + immediate release: `finally` runs a microtask AFTER
147
+ // resolve, so keys buffered in the SAME tick (a paste like "2\n1") would
148
+ // otherwise keep reducing and repainting a highlight that diverges from
149
+ // the resolved index (codex find, verified). Settle → release NOW; the
150
+ // finally's idempotent release stays as the belt.
151
+ let settled = false;
152
+ const settle = () => {
153
+ settled = true;
154
+ release();
155
+ };
156
+ release = io.rawKeys((key) => {
157
+ if (settled)
158
+ return;
159
+ const res = reduceSelectKey(state, key, count, hotkeys);
160
+ if (res.kind === "cancel") {
161
+ settle();
162
+ reject(res.signal
163
+ ? new CliError("Interrupted.", EXIT.INTERRUPT)
164
+ : new CliError("Cancelled.", EXIT.USAGE));
165
+ return;
166
+ }
167
+ if (res.kind === "commit") {
168
+ state = { index: res.index, buffer: "" };
169
+ notice = "";
170
+ collapse(options[res.index]);
171
+ settle();
172
+ resolve(res.index);
173
+ return;
174
+ }
175
+ if (res.kind === "reject") {
176
+ state = res.state;
177
+ notice = res.message;
178
+ paint();
179
+ return;
180
+ }
181
+ state = res.state;
182
+ notice = "";
183
+ paint();
184
+ });
185
+ });
186
+ }
187
+ finally {
188
+ // Order matters: stop consuming keys BEFORE restoring the cursor, so a key
189
+ // pressed during teardown cannot repaint over the restored terminal.
190
+ release();
191
+ io.write(SHOW_CURSOR);
192
+ }
193
+ }
194
+ /**
195
+ * Confirm. Arrow-driven Yes/No when the terminal allows, y/N text otherwise.
196
+ * (TTY only — callers gate; -y bypasses upstream.)
197
+ */
198
+ export async function confirm(io, prompt, def = false, c) {
199
+ // y/n commit IMMEDIATELY (hotkeys) — the [y/N] muscle memory this CLI's own
200
+ // fallback trains must never be silently ignored (dual-review find: `n`+Enter
201
+ // at a default-Yes confirm used to run the install against a typed no).
202
+ if (io.rawKeys)
203
+ return (await promptSelect(io, prompt, ["Yes", "No"], def ? 0 : 1, c, { y: 0, n: 1 }, "y/n")) === 0;
53
204
  const suffix = def ? "[Y/n]" : "[y/N]";
54
205
  const answer = (await io.question(`${prompt} ${suffix} `)).trim().toLowerCase();
55
206
  if (answer === "")
@@ -1,4 +1,4 @@
1
1
  import type { ResolveResponse } from "./contract.js";
2
2
  export declare const UPGRADE_URL = "https://promptdock.ai/pricing";
3
3
  /** Throws the DX3-copy CliError for a deny verdict; returns for ok/already_entitled. */
4
- export declare function assertInstallable(resolve: ResolveResponse, refString: string): void;
4
+ export declare function assertInstallable(resolve: ResolveResponse, refString: string, cliVersion: string): void;
package/dist/verdicts.js CHANGED
@@ -1,11 +1,53 @@
1
1
  import { CliError, EXIT } from "./errors.js";
2
2
  import { retryHours } from "./ui.js";
3
+ import { CANONICAL_INSTALL_COMMAND, CLI_DOS_MAX_SKILL_FILES, CLI_DOS_MAX_SKILL_TOTAL_BYTES, } from "./generated/constants.js";
3
4
  export const UPGRADE_URL = "https://promptdock.ai/pricing";
5
+ /** Numeric semver compare; mirrors compareCliVersions in lib/validation/skills.ts. */
6
+ function cmpVersion(a, b) {
7
+ const pa = a.split(".").map((n) => Number(n) || 0);
8
+ const pb = b.split(".").map((n) => Number(n) || 0);
9
+ return (pa[0] - pb[0]) || (pa[1] - pb[1]) || (pa[2] - pb[2]);
10
+ }
11
+ /**
12
+ * PACKAGE-SCOPED compatibility, checked BEFORE the metered install POST.
13
+ *
14
+ * Two reasons this lives here and not only in `assertSafeManifest`:
15
+ * 1. `POST /cli/skills/{id}/install` mints an entitlement ticket under the shared
16
+ * 15/day premium cap. Failing after it burns one of the user's daily slots on a
17
+ * provably-doomed install.
18
+ * 2. A package this CLI is too old to install is a POLICY refusal (EXIT.DENIED — the
19
+ * code whose own doc string reads "tier, cap, denial, rate limit, version floor"),
20
+ * not a supply-chain signal. `assertSafeManifest` throws EXIT.INTEGRITY because
21
+ * there it means the server sent something impossible; here it means "update me".
22
+ *
23
+ * `assertSafeManifest` stays as the untrusted-server backstop — this check makes it
24
+ * unreachable in practice, never redundant.
25
+ */
26
+ function assertPackageCompatible(resolve, cliVersion) {
27
+ const upgrade = `Update: ${CANONICAL_INSTALL_COMMAND} … (or: npm i -g promptdock@latest)`;
28
+ const title = resolve.title ? `"${resolve.title}"` : "This skill";
29
+ const min = resolve.min_cli_version;
30
+ if (typeof min === "string" && min.length > 0 && cmpVersion(cliVersion, min) < 0) {
31
+ throw new CliError(`${title} needs promptdock CLI ${min} or newer — you are on ${cliVersion}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
32
+ }
33
+ // Belt and braces for a server too old to send min_cli_version: compare the shape
34
+ // the resolve response already reports against this CLI's own fuses.
35
+ const files = Number(resolve.file_count);
36
+ if (Number.isFinite(files) && files > CLI_DOS_MAX_SKILL_FILES) {
37
+ throw new CliError(`${title} has ${files} files — this CLI installs at most ${CLI_DOS_MAX_SKILL_FILES}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
38
+ }
39
+ const total = Number(resolve.total_bytes);
40
+ if (Number.isFinite(total) && total > CLI_DOS_MAX_SKILL_TOTAL_BYTES) {
41
+ const mb = (n) => `${(n / (1024 * 1024)).toFixed(1)}MB`;
42
+ throw new CliError(`${title} is ${mb(total)} — this CLI installs at most ${mb(CLI_DOS_MAX_SKILL_TOTAL_BYTES)}.`, EXIT.DENIED, { footer: "upgrade_required", hint: upgrade });
43
+ }
44
+ }
4
45
  /** Throws the DX3-copy CliError for a deny verdict; returns for ok/already_entitled. */
5
- export function assertInstallable(resolve, refString) {
46
+ export function assertInstallable(resolve, refString, cliVersion) {
6
47
  switch (resolve.verdict) {
7
48
  case "ok":
8
49
  case "already_entitled":
50
+ assertPackageCompatible(resolve, cliVersion);
9
51
  return;
10
52
  case "not_found":
11
53
  throw new CliError(`skill not found: ${refString} — check the ref (the format is handle/slug; a pasted promptdock.ai skill URL also works)`, EXIT.DENIED, { footer: "not_found" });
package/package.json CHANGED
@@ -1,28 +1,46 @@
1
1
  {
2
2
  "name": "promptdock",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Install AI agent skills from PromptDock — npx promptdock@latest install <handle>/<slug>",
5
- "keywords": ["promptdock", "skills", "ai", "agents", "claude", "cli"],
5
+ "keywords": [
6
+ "promptdock",
7
+ "skills",
8
+ "ai",
9
+ "agents",
10
+ "claude",
11
+ "cli"
12
+ ],
6
13
  "homepage": "https://promptdock.ai",
7
14
  "repository": {
8
15
  "type": "git",
9
16
  "url": "git+https://github.com/klicklabs/promptdock.ai.git",
10
17
  "directory": "packages/cli"
11
18
  },
12
- "bugs": { "url": "https://promptdock.ai/docs/cli/errors" },
13
- "license": "UNLICENSED",
19
+ "bugs": {
20
+ "url": "https://promptdock.ai/docs/cli/errors"
21
+ },
22
+ "license": "MIT",
14
23
  "type": "module",
15
- "bin": { "promptdock": "dist/index.js" },
16
- "files": ["dist", "README.md"],
17
- "engines": { "node": ">=18" },
24
+ "bin": {
25
+ "promptdock": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
18
34
  "scripts": {
19
35
  "build": "tsc -p tsconfig.json",
20
- "test": "vitest run",
21
- "prepublishOnly": "npm run build"
36
+ "test": "npm run typecheck && vitest run",
37
+ "prepublishOnly": "npm run build",
38
+ "typecheck": "tsc -p tsconfig.test.json"
22
39
  },
23
40
  "devDependencies": {
24
41
  "@types/node": "^20",
25
42
  "typescript": "^5",
26
43
  "vitest": "^4.1.8"
27
- }
44
+ },
45
+ "gitHead": "53545d74fc0baeec63684c96b49134e93625d63a"
28
46
  }