@nomai/nomai 0.1.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,473 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { mkdir, mkdtemp, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
4
+ import { createHash } from "node:crypto";
5
+ import os from "node:os";
6
+ //#region src/core/manifest.ts
7
+ const MANIFEST_FILENAME = "nomai-manifest.json";
8
+ function manifestPath(installDir) {
9
+ return path.join(installDir, MANIFEST_FILENAME);
10
+ }
11
+ function hashBytes(contents) {
12
+ return createHash("sha256").update(contents).digest("hex");
13
+ }
14
+ /** @returns null when no manifest exists; throws on a corrupt/foreign one. */
15
+ async function readManifest(installDir) {
16
+ const file = manifestPath(installDir);
17
+ let raw;
18
+ try {
19
+ raw = await readFile(file, "utf8");
20
+ } catch (error) {
21
+ if (error.code === "ENOENT") return null;
22
+ throw error;
23
+ }
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(raw);
27
+ } catch {
28
+ throw new Error(`Corrupt manifest at ${file}: not valid JSON. Remove it and reinstall.`);
29
+ }
30
+ if (!isManifest(parsed)) throw new Error(`Unrecognized manifest at ${file}. Remove it and reinstall.`);
31
+ return parsed;
32
+ }
33
+ async function writeManifest(installDir, manifest) {
34
+ await mkdir(installDir, { recursive: true });
35
+ await writeFile(manifestPath(installDir), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
36
+ }
37
+ async function removeManifest(installDir) {
38
+ await rm(manifestPath(installDir), { force: true });
39
+ }
40
+ function isManifest(value) {
41
+ if (typeof value !== "object" || value === null) return false;
42
+ const m = value;
43
+ return m.schemaVersion === 1 && typeof m.nomaiVersion === "string" && typeof m.source === "string" && typeof m.harness === "string" && (m.scope === "project" || m.scope === "global") && typeof m.installedAt === "string" && Array.isArray(m.files) && m.files.every((f) => typeof f === "object" && f !== null && typeof f.path === "string" && typeof f.sha256 === "string");
44
+ }
45
+ function createManifest(options) {
46
+ return {
47
+ schemaVersion: 1,
48
+ nomaiVersion: options.nomaiVersion,
49
+ source: options.source,
50
+ harness: options.harness,
51
+ scope: options.scope,
52
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
53
+ files: options.files
54
+ };
55
+ }
56
+ //#endregion
57
+ //#region src/core/installer.ts
58
+ /**
59
+ * Generic install/uninstall/status engine shared by all harness adapters.
60
+ * defineHarness() wires adapter methods to these functions by default;
61
+ * adapters with special needs override the methods instead.
62
+ *
63
+ * This module is the only place in the codebase that writes to disk.
64
+ */
65
+ async function installHarness(adapter, ctx) {
66
+ if (!ctx.payload) throw new Error(`install(${adapter.id}) requires a payload source.`);
67
+ const installDir = adapter.resolveInstallDir(ctx.scope, ctx);
68
+ const merged = mergePayload(await ctx.payload.readSharedFiles(), await ctx.payload.readHarnessOverlay(adapter.id));
69
+ const files = await adapter.transformPayload(merged, {
70
+ projectDir: ctx.projectDir,
71
+ homeDir: ctx.homeDir,
72
+ scope: ctx.scope,
73
+ nomaiVersion: ctx.nomaiVersion
74
+ });
75
+ const nextEntries = files.map((file) => ({
76
+ path: toPosix(file.relPath),
77
+ sha256: hashBytes(file.contents)
78
+ })).sort((a, b) => comparePaths(a.path, b.path));
79
+ const existing = await readManifest(installDir);
80
+ if (existing && !ctx.force) {
81
+ if (sameEntries(existing.files, nextEntries) && await allFilesMatch(installDir, nextEntries)) return {
82
+ installDir,
83
+ filesWritten: [],
84
+ filesRemoved: [],
85
+ skipped: true
86
+ };
87
+ }
88
+ const nextPaths = new Set(nextEntries.map((entry) => entry.path));
89
+ const orphans = (existing?.files ?? []).filter((entry) => !nextPaths.has(entry.path));
90
+ if (ctx.dryRun) return {
91
+ installDir,
92
+ filesWritten: nextEntries.map((entry) => entry.path),
93
+ filesRemoved: orphans.map((entry) => entry.path),
94
+ skipped: false
95
+ };
96
+ for (const file of files) {
97
+ const target = path.join(installDir, file.relPath);
98
+ await mkdir(path.dirname(target), { recursive: true });
99
+ await writeFile(target, file.contents);
100
+ }
101
+ const removed = await removeOwnedFiles(installDir, orphans, ctx.force);
102
+ await writeManifest(installDir, createManifest({
103
+ nomaiVersion: ctx.nomaiVersion,
104
+ source: ctx.payload.description,
105
+ harness: adapter.id,
106
+ scope: ctx.scope,
107
+ files: nextEntries
108
+ }));
109
+ return {
110
+ installDir,
111
+ filesWritten: nextEntries.map((entry) => entry.path),
112
+ filesRemoved: removed.removed,
113
+ skipped: false
114
+ };
115
+ }
116
+ async function uninstallHarness(adapter, ctx) {
117
+ const installDir = adapter.resolveInstallDir(ctx.scope, ctx);
118
+ const manifest = await readManifest(installDir);
119
+ if (!manifest) return {
120
+ installDir,
121
+ notInstalled: true,
122
+ filesRemoved: [],
123
+ filesKept: []
124
+ };
125
+ if (ctx.dryRun) return {
126
+ installDir,
127
+ notInstalled: false,
128
+ filesRemoved: manifest.files.map((entry) => entry.path),
129
+ filesKept: []
130
+ };
131
+ const { removed, kept } = await removeOwnedFiles(installDir, manifest.files, ctx.force);
132
+ await removeManifest(installDir);
133
+ await pruneEmptyDirs(installDir, removed);
134
+ return {
135
+ installDir,
136
+ notInstalled: false,
137
+ filesRemoved: removed,
138
+ filesKept: kept
139
+ };
140
+ }
141
+ async function statusHarness(adapter, ctx) {
142
+ const installDir = adapter.resolveInstallDir(ctx.scope, ctx);
143
+ const detected = ctx.scope === "project" ? await adapter.detectProject(ctx.projectDir) : await adapter.detectGlobal(ctx.homeDir);
144
+ const manifest = await readManifest(installDir);
145
+ if (!manifest) return {
146
+ harnessId: adapter.id,
147
+ scope: ctx.scope,
148
+ installDir,
149
+ detected,
150
+ installed: false,
151
+ modifiedFiles: [],
152
+ missingFiles: []
153
+ };
154
+ const modified = [];
155
+ const missing = [];
156
+ for (const entry of manifest.files) try {
157
+ if (hashBytes(await readFile(path.join(installDir, entry.path))) !== entry.sha256) modified.push(entry.path);
158
+ } catch (error) {
159
+ if (error.code === "ENOENT") missing.push(entry.path);
160
+ else throw error;
161
+ }
162
+ return {
163
+ harnessId: adapter.id,
164
+ scope: ctx.scope,
165
+ installDir,
166
+ detected,
167
+ installed: true,
168
+ installedVersion: manifest.nomaiVersion,
169
+ installedAt: manifest.installedAt,
170
+ modifiedFiles: modified,
171
+ missingFiles: missing
172
+ };
173
+ }
174
+ /** Overlay wins over shared on the same relPath. */
175
+ function mergePayload(shared, overlay) {
176
+ const byPath = /* @__PURE__ */ new Map();
177
+ for (const file of shared) byPath.set(toPosix(file.relPath), file);
178
+ for (const file of overlay) byPath.set(toPosix(file.relPath), file);
179
+ return [...byPath.values()];
180
+ }
181
+ /**
182
+ * Delete manifest-owned files. A file whose content no longer matches its
183
+ * manifest hash was modified by the user — keep it unless force.
184
+ * Missing files are treated as already removed.
185
+ */
186
+ async function removeOwnedFiles(installDir, entries, force) {
187
+ const removed = [];
188
+ const kept = [];
189
+ for (const entry of entries) {
190
+ const target = path.join(installDir, entry.path);
191
+ let contents;
192
+ try {
193
+ contents = await readFile(target);
194
+ } catch (error) {
195
+ if (error.code === "ENOENT") continue;
196
+ throw error;
197
+ }
198
+ if (!force && hashBytes(contents) !== entry.sha256) {
199
+ kept.push(entry.path);
200
+ continue;
201
+ }
202
+ await rm(target, { force: true });
203
+ removed.push(entry.path);
204
+ }
205
+ return {
206
+ removed,
207
+ kept
208
+ };
209
+ }
210
+ /**
211
+ * Remove directories left empty by deletions, walking up from each removed
212
+ * file. Never removes the install dir itself — nomai never deletes .claude/.
213
+ */
214
+ async function pruneEmptyDirs(installDir, removedPaths) {
215
+ const root = path.resolve(installDir);
216
+ const candidates = /* @__PURE__ */ new Set();
217
+ for (const relPath of removedPaths) {
218
+ let dir = path.dirname(path.resolve(installDir, relPath));
219
+ while (dir.startsWith(root) && dir !== root) {
220
+ candidates.add(dir);
221
+ dir = path.dirname(dir);
222
+ }
223
+ }
224
+ const ordered = [...candidates].sort((a, b) => b.length - a.length);
225
+ for (const dir of ordered) try {
226
+ if ((await readdir(dir)).length === 0) await rmdir(dir);
227
+ } catch {}
228
+ }
229
+ async function allFilesMatch(installDir, entries) {
230
+ for (const entry of entries) try {
231
+ if (hashBytes(await readFile(path.join(installDir, entry.path))) !== entry.sha256) return false;
232
+ } catch {
233
+ return false;
234
+ }
235
+ return true;
236
+ }
237
+ function sameEntries(a, b) {
238
+ if (a.length !== b.length) return false;
239
+ const sortedA = [...a].sort((x, y) => comparePaths(x.path, y.path));
240
+ const sortedB = [...b].sort((x, y) => comparePaths(x.path, y.path));
241
+ return sortedA.every((entry, i) => entry.path === sortedB[i].path && entry.sha256 === sortedB[i].sha256);
242
+ }
243
+ /** Codepoint sort: stable across machines/locales (localeCompare is not). */
244
+ function comparePaths(a, b) {
245
+ return a < b ? -1 : a > b ? 1 : 0;
246
+ }
247
+ function toPosix(relPath) {
248
+ return relPath.split(path.sep).join("/");
249
+ }
250
+ //#endregion
251
+ //#region src/harnesses/define.ts
252
+ /**
253
+ * Build a HarnessAdapter from declarative config. A typical adapter is:
254
+ *
255
+ * export const claudeCode = defineHarness({
256
+ * id: "claude-code",
257
+ * displayName: "Claude Code",
258
+ * configDirName: ".claude",
259
+ * });
260
+ *
261
+ * Harnesses that need per-harness compilation override transformPayload;
262
+ * harnesses with entirely custom mechanics (e.g. settings.json merges for
263
+ * hooks) can override install/uninstall/status wholesale.
264
+ */
265
+ function defineHarness(config) {
266
+ const adapter = {
267
+ id: config.id,
268
+ displayName: config.displayName,
269
+ configDirName: config.configDirName,
270
+ detectProject: config.detectProject ?? ((projectDir) => dirExists(path.join(projectDir, config.configDirName))),
271
+ detectGlobal: config.detectGlobal ?? ((homeDir) => dirExists(path.join(homeDir, config.configDirName))),
272
+ resolveInstallDir: config.resolveInstallDir ?? ((scope, ctx) => path.join(scope === "project" ? ctx.projectDir : ctx.homeDir, config.configDirName)),
273
+ transformPayload: config.transformPayload ?? (async (files) => files),
274
+ install: config.install ?? ((ctx) => installHarness(adapter, ctx)),
275
+ uninstall: config.uninstall ?? ((ctx) => uninstallHarness(adapter, ctx)),
276
+ status: config.status ?? ((ctx) => statusHarness(adapter, ctx))
277
+ };
278
+ return adapter;
279
+ }
280
+ async function dirExists(target) {
281
+ try {
282
+ return (await stat(target)).isDirectory();
283
+ } catch {
284
+ return false;
285
+ }
286
+ }
287
+ //#endregion
288
+ //#region src/harnesses/index.ts
289
+ /**
290
+ * Register new harnesses here. Adding one:
291
+ * 1. Create src/harnesses/<name>.ts with defineHarness({...}).
292
+ * 2. Add it to this array.
293
+ * 3. Create payload/<name>/ for harness-specific payload files (optional).
294
+ */
295
+ const harnesses = [defineHarness({
296
+ id: "claude-code",
297
+ displayName: "Claude Code",
298
+ configDirName: ".claude"
299
+ })];
300
+ //#endregion
301
+ //#region src/core/registry.ts
302
+ function listHarnesses() {
303
+ return [...harnesses];
304
+ }
305
+ function getHarness(id) {
306
+ const adapter = harnesses.find((harness) => harness.id === id);
307
+ if (!adapter) {
308
+ const valid = harnesses.map((harness) => harness.id).join(", ");
309
+ throw new Error(`Unknown harness "${id}". Valid harnesses: ${valid}`);
310
+ }
311
+ return adapter;
312
+ }
313
+ //#endregion
314
+ //#region src/core/detect.ts
315
+ /**
316
+ * Ask every registered harness whether it is present at project and global
317
+ * scope. The report preseeds the wizard's harness multiselect and drives
318
+ * the defaults used by --yes.
319
+ */
320
+ async function detectHarnesses(ctx) {
321
+ const results = await Promise.all(listHarnesses().map(async (adapter) => ({
322
+ harnessId: adapter.id,
323
+ project: await adapter.detectProject(ctx.projectDir),
324
+ global: await adapter.detectGlobal(ctx.homeDir)
325
+ })));
326
+ return {
327
+ results,
328
+ detectedIds: results.filter((r) => r.project || r.global).map((r) => r.harnessId),
329
+ projectDetectedIds: results.filter((r) => r.project).map((r) => r.harnessId)
330
+ };
331
+ }
332
+ //#endregion
333
+ //#region src/core/payload.ts
334
+ /**
335
+ * Default payload source: the Nomai GitHub repo, fetched as a codeload
336
+ * tarball of `main` at install time (same model as BuilderIO's
337
+ * `npx @agent-native/skills`). The npm package itself ships no payload.
338
+ */
339
+ const DEFAULT_SOURCE = "A-MCode/nomai-installer";
340
+ /** Name of the payload directory inside the repo. */
341
+ const PAYLOAD_DIR = "payload";
342
+ /** Subdirectory holding the canonical payload shared by all harnesses. */
343
+ const SHARED_DIR = "shared";
344
+ /** Accepts "owner/repo", "owner/repo#ref", or a github.com URL (optionally /tree/<ref>). */
345
+ function parseGitHubSource(input) {
346
+ const shorthand = input.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:#(.+))?$/);
347
+ if (shorthand) return {
348
+ owner: shorthand[1],
349
+ repo: shorthand[2].replace(/\.git$/, ""),
350
+ ref: shorthand[3]
351
+ };
352
+ let url;
353
+ try {
354
+ url = new URL(input);
355
+ } catch {
356
+ return null;
357
+ }
358
+ if (url.hostname !== "github.com") return null;
359
+ const parts = url.pathname.split("/").filter(Boolean);
360
+ const [owner, repo] = parts;
361
+ if (!owner || !repo) return null;
362
+ const treeIndex = parts.indexOf("tree");
363
+ return {
364
+ owner,
365
+ repo: repo.replace(/\.git$/, ""),
366
+ ref: treeIndex >= 0 && parts[treeIndex + 1] ? parts.slice(treeIndex + 1).join("/") : void 0
367
+ };
368
+ }
369
+ /**
370
+ * Turn a --source value (or the default) into a live PayloadSource:
371
+ * an existing local path wins, otherwise a GitHub download.
372
+ */
373
+ async function resolvePayloadSource(input) {
374
+ const source = input ?? "A-MCode/nomai-installer";
375
+ const localRoot = path.resolve(source);
376
+ if (await exists(localRoot)) {
377
+ await assertPayloadRoot(localRoot, source);
378
+ return new DirectoryPayloadSource(source, localRoot);
379
+ }
380
+ const parsed = parseGitHubSource(source);
381
+ if (!parsed) throw new Error(`Payload source not found: ${source}. Use a local path, GitHub owner/repo[#ref], or GitHub URL.`);
382
+ return materializeGitHubSource(parsed);
383
+ }
384
+ async function materializeGitHubSource(ref) {
385
+ const gitRef = ref.ref ?? "main";
386
+ const description = `${ref.owner}/${ref.repo}#${gitRef}`;
387
+ const url = `https://codeload.github.com/${ref.owner}/${ref.repo}/tar.gz/${encodeURIComponent(gitRef)}`;
388
+ const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "nomai-payload-"));
389
+ try {
390
+ const response = await fetch(url, { headers: { "user-agent": "nomai" } });
391
+ if (!response.ok) throw new Error(`Could not download ${description}: HTTP ${response.status}.`);
392
+ const archive = path.join(tmpRoot, "source.tgz");
393
+ await writeFile(archive, Buffer.from(await response.arrayBuffer()));
394
+ const extracted = spawnSync("tar", [
395
+ "-xzf",
396
+ archive,
397
+ "-C",
398
+ tmpRoot
399
+ ], {
400
+ stdio: "pipe",
401
+ encoding: "utf8"
402
+ });
403
+ if (extracted.status !== 0) throw new Error(`Could not extract ${description}: ${extracted.stderr || extracted.stdout}`);
404
+ const repoDir = (await readdir(tmpRoot, { withFileTypes: true })).find((entry) => entry.isDirectory());
405
+ if (!repoDir) throw new Error(`Downloaded archive for ${description} did not contain a repo directory.`);
406
+ const root = path.join(tmpRoot, repoDir.name);
407
+ await assertPayloadRoot(root, description);
408
+ return new DirectoryPayloadSource(description, root, () => rm(tmpRoot, {
409
+ recursive: true,
410
+ force: true
411
+ }));
412
+ } catch (error) {
413
+ await rm(tmpRoot, {
414
+ recursive: true,
415
+ force: true
416
+ });
417
+ throw error;
418
+ }
419
+ }
420
+ /** Reads payload files from a repo checkout/extract on disk. */
421
+ var DirectoryPayloadSource = class {
422
+ description;
423
+ rootDir;
424
+ cleanup;
425
+ constructor(description, rootDir, cleanup) {
426
+ this.description = description;
427
+ this.rootDir = rootDir;
428
+ this.cleanup = cleanup;
429
+ }
430
+ async readSharedFiles() {
431
+ return readTree(path.join(this.rootDir, PAYLOAD_DIR, SHARED_DIR));
432
+ }
433
+ async readHarnessOverlay(harnessId) {
434
+ if (harnessId === "shared") return [];
435
+ return readTree(path.join(this.rootDir, PAYLOAD_DIR, harnessId));
436
+ }
437
+ async dispose() {
438
+ await this.cleanup?.();
439
+ }
440
+ };
441
+ async function assertPayloadRoot(root, description) {
442
+ if (!await exists(path.join(root, "payload"))) throw new Error(`${description} does not contain a ${PAYLOAD_DIR}/ directory — not a Nomai payload source.`);
443
+ }
444
+ /** Recursively read a directory into PayloadFiles; missing dir → []. Skips .gitkeep placeholders. */
445
+ async function readTree(dir, prefix = "") {
446
+ let entries;
447
+ try {
448
+ entries = await readdir(dir, { withFileTypes: true });
449
+ } catch (error) {
450
+ if (error.code === "ENOENT") return [];
451
+ throw error;
452
+ }
453
+ const files = [];
454
+ for (const entry of entries) {
455
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
456
+ if (entry.isDirectory()) files.push(...await readTree(path.join(dir, entry.name), relPath));
457
+ else if (entry.isFile() && entry.name !== ".gitkeep") files.push({
458
+ relPath,
459
+ contents: await readFile(path.join(dir, entry.name))
460
+ });
461
+ }
462
+ return files.sort((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0);
463
+ }
464
+ async function exists(target) {
465
+ try {
466
+ await stat(target);
467
+ return true;
468
+ } catch {
469
+ return false;
470
+ }
471
+ }
472
+ //#endregion
473
+ export { getHarness as a, installHarness as c, MANIFEST_FILENAME as d, readManifest as f, detectHarnesses as i, statusHarness as l, parseGitHubSource as n, listHarnesses as o, resolvePayloadSource as r, defineHarness as s, DEFAULT_SOURCE as t, uninstallHarness as u };
@@ -0,0 +1,26 @@
1
+ import { createRequire } from "node:module";
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
12
+ key = keys[i];
13
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
14
+ get: ((k) => from[k]).bind(null, key),
15
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
+ });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
21
+ value: mod,
22
+ enumerable: true
23
+ }) : target, mod));
24
+ var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
25
+ //#endregion
26
+ export { __require as n, __toESM as r, __commonJSMin as t };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@nomai/nomai",
3
+ "version": "0.1.0",
4
+ "description": "Install Nomai skills, hooks, and commands into AI coding harnesses",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/A-MCode/nomai-installer.git"
10
+ },
11
+ "homepage": "https://github.com/A-MCode/nomai-installer#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/A-MCode/nomai-installer/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "bin": {
22
+ "nomai": "./dist/cli.mjs"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "scripts": {
35
+ "dev": "tsx src/cli.ts",
36
+ "build": "tsdown",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "typecheck": "tsc --noEmit",
40
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
41
+ },
42
+ "devDependencies": {
43
+ "@clack/prompts": "^1.7.0",
44
+ "@types/node": "^22.10.0",
45
+ "@vercel/detect-agent": "^1.2.3",
46
+ "commander": "^15.0.0",
47
+ "tsdown": "^0.22.4",
48
+ "tsx": "^4.20.0",
49
+ "typescript": "^5.9.0",
50
+ "vitest": "^4.1.0"
51
+ }
52
+ }