clawlodge-cli 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.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # ClawLodge CLI
2
+
3
+ Pack and publish OpenClaw config workspaces to ClawLodge.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g clawlodge-cli
9
+ ```
10
+
11
+ ## Basic usage
12
+
13
+ ```bash
14
+ clawlodge login
15
+ clawlodge pack
16
+ clawlodge publish
17
+ ```
18
+
19
+ ## README and Name
20
+
21
+ ```bash
22
+ clawlodge publish --name "My Workspace"
23
+ clawlodge publish --readme /path/to/README.md
24
+ ```
25
+
26
+ If you do not pass `--name`, the CLI derives it from the workspace folder name.
27
+ If you do not pass `--readme`, the publish API generates the README on the server.
28
+
29
+ ## Help
30
+
31
+ ```bash
32
+ clawlodge help
33
+ ```
34
+
35
+ Create a PAT in `https://clawlodge.com/settings`, then run:
36
+
37
+ ```bash
38
+ clawlodge login
39
+ clawlodge whoami
40
+ ```
41
+
42
+ If the default OpenClaw workspace is not available under `~/.openclaw`, pass an explicit path with `--workspace`.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from "../lib/core.mjs";
4
+
5
+ runCli().catch((error) => {
6
+ console.error(error instanceof Error ? error.message : String(error));
7
+ process.exit(1);
8
+ });
package/lib/core.mjs ADDED
@@ -0,0 +1,480 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import readline from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+
7
+ const ALLOWED_ROOT_FILES = new Set(["AGENTS.md", "SOUL.md", "TOOLS.md", "README.md"]);
8
+ const ALLOWED_PREFIXES = ["skills/", "examples/", "templates/", "prompts/", ".openclaw/"];
9
+ const BLOCKED_DIRS = new Set([".git", ".next", "node_modules", "dist", "build", "coverage", ".idea", ".vscode", "tmp", "temp", "logs", "data"]);
10
+ const BLOCKED_FILE_NAMES = [/^\.env(\..+)?$/i, /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i];
11
+ const BLOCKED_FILE_EXTENSIONS = new Set([".pem", ".key", ".p12", ".pfx", ".db", ".sqlite", ".sqlite3", ".log"]);
12
+ const TEXT_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".rs", ".java", ".kt", ".sh", ".zsh", ".bash", ".html", ".css", ".scss", ".sql"]);
13
+ const ALLOWED_LICENSES = new Set(["MIT", "Apache-2.0", "CC-BY-4.0", "BSD-3-Clause", "GPL-3.0-only"]);
14
+ const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
15
+ const MAX_FILE_BYTES = 128 * 1024;
16
+ const MAX_EXCERPT_CHARS = 1600;
17
+ const DEFAULT_ORIGIN = "https://clawlodge.com";
18
+ const CONFIG_PATH = path.join(os.homedir(), ".config", "clawlodge", "config.json");
19
+ const REDACTION_RULES = [
20
+ [/\bsk-[A-Za-z0-9]{20,}\b/g, "[REDACTED_OPENAI_KEY]"],
21
+ [/\bsk-or-v1-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_OPENROUTER_KEY]"],
22
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[REDACTED_GITHUB_TOKEN]"],
23
+ [/\bAIza[0-9A-Za-z\-_]{20,}\b/g, "[REDACTED_GEMINI_KEY]"],
24
+ [/\b(claw_pat_[A-Za-z0-9_-]{12,})\b/g, "[REDACTED_CLAW_PAT]"],
25
+ [/(Authorization:\s*Bearer\s+)[^\s"'`]+/gi, "$1[REDACTED_BEARER_TOKEN]"],
26
+ [/\b(Bearer\s+)[^\s"'`]+/g, "$1[REDACTED_BEARER_TOKEN]"],
27
+ [/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]"],
28
+ [/\b(?:\+?\d[\d\s().-]{7,}\d)\b/g, "[REDACTED_PHONE]"],
29
+ [/\b(?:10\.\d{1,3}|192\.168\.\d{1,3}|172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3})\.\d{1,3}\b/g, "[REDACTED_PRIVATE_IP]"],
30
+ [/https?:\/\/[A-Za-z0-9.-]*?(?:internal|corp|local)[A-Za-z0-9./:_-]*/gi, "[REDACTED_INTERNAL_URL]"],
31
+ ];
32
+
33
+ function slugify(inputValue) {
34
+ return inputValue.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lobster";
35
+ }
36
+
37
+ function titleCaseFromSlug(inputValue) {
38
+ return inputValue.split(/[-_/]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
39
+ }
40
+
41
+ function parseArgs(argv) {
42
+ const [command = "help", ...rest] = argv;
43
+ const options = {};
44
+ for (let index = 0; index < rest.length; index += 1) {
45
+ const current = rest[index];
46
+ if (!current.startsWith("--")) continue;
47
+ const key = current.slice(2);
48
+ const next = rest[index + 1];
49
+ if (!next || next.startsWith("--")) {
50
+ options[key] = "true";
51
+ continue;
52
+ }
53
+ options[key] = next;
54
+ index += 1;
55
+ }
56
+ return { command, options };
57
+ }
58
+
59
+ function printHelp() {
60
+ console.log(`ClawLodge CLI
61
+
62
+ Basic usage:
63
+ clawlodge login
64
+ clawlodge pack
65
+ clawlodge publish
66
+
67
+ Commands:
68
+ clawlodge login
69
+ Save a PAT locally after you create it in https://clawlodge.com/settings
70
+
71
+ clawlodge whoami
72
+ Show the user bound to the saved PAT
73
+
74
+ clawlodge logout
75
+ Remove the saved local PAT
76
+
77
+ clawlodge pack
78
+ Pack the default OpenClaw workspace into .clawlodge/workspace-publish.json
79
+
80
+ clawlodge publish
81
+ Pack the default OpenClaw workspace and publish to https://clawlodge.com
82
+
83
+ clawlodge help
84
+ Show this help text
85
+
86
+ Advanced usage:
87
+ clawlodge login --origin https://clawlodge.com
88
+ clawlodge pack --name "My Workspace"
89
+ clawlodge publish --readme /tmp/README.md
90
+ clawlodge pack --workspace ~/my-workspace --out /tmp/workspace-publish.json
91
+ clawlodge publish --workspace ~/my-workspace --token claw_pat_xxx
92
+ clawlodge publish --workspace ~/my-workspace --origin https://clawlodge.com
93
+
94
+ Environment variables:
95
+ CLAWLODGE_PAT
96
+ CLAWLODGE_ORIGIN
97
+ `);
98
+ }
99
+
100
+ async function resolveWorkspaceRoot(explicitWorkspace) {
101
+ if (explicitWorkspace?.trim()) {
102
+ return path.resolve(explicitWorkspace.trim());
103
+ }
104
+
105
+ const openClawHome = path.join(process.env.HOME || process.env.USERPROFILE || "~", ".openclaw");
106
+ const preferredPath = path.join(openClawHome, "workspace");
107
+
108
+ try {
109
+ const stat = await fs.stat(preferredPath);
110
+ if (stat.isDirectory()) {
111
+ return preferredPath;
112
+ }
113
+ } catch {
114
+ // fall through to workspace* discovery
115
+ }
116
+
117
+ try {
118
+ const entries = await fs.readdir(openClawHome, { withFileTypes: true });
119
+ const workspaceDirs = await Promise.all(
120
+ entries
121
+ .filter((entry) => entry.isDirectory() && /^workspace/i.test(entry.name))
122
+ .map(async (entry) => {
123
+ const fullPath = path.join(openClawHome, entry.name);
124
+ const stat = await fs.stat(fullPath);
125
+ return { fullPath, mtimeMs: stat.mtimeMs };
126
+ }),
127
+ );
128
+
129
+ workspaceDirs.sort((a, b) => b.mtimeMs - a.mtimeMs);
130
+ if (workspaceDirs[0]) {
131
+ return workspaceDirs[0].fullPath;
132
+ }
133
+ } catch {
134
+ // fall through to error
135
+ }
136
+
137
+ throw new Error(
138
+ "No default OpenClaw workspace found under ~/.openclaw. If your workspace is in another path, run clawlodge pack --workspace /path/to/workspace or clawlodge publish --workspace /path/to/workspace.",
139
+ );
140
+ }
141
+
142
+ function normalizeRelativePath(root, absolutePath) {
143
+ return path.relative(root, absolutePath).split(path.sep).join("/");
144
+ }
145
+
146
+ function isBlockedFile(relativePath) {
147
+ const normalized = relativePath.replace(/^\.\/+/, "");
148
+ const parts = normalized.split("/").filter(Boolean);
149
+ if (!parts.length) return true;
150
+ if (parts.some((part) => BLOCKED_DIRS.has(part))) return true;
151
+ const basename = parts.at(-1) ?? "";
152
+ if (BLOCKED_FILE_NAMES.some((pattern) => pattern.test(basename))) return true;
153
+ return BLOCKED_FILE_EXTENSIONS.has(path.extname(basename).toLowerCase());
154
+ }
155
+
156
+ function isAllowedFile(relativePath) {
157
+ const normalized = relativePath.replace(/^\.\/+/, "");
158
+ if (ALLOWED_ROOT_FILES.has(normalized)) return true;
159
+ return ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix));
160
+ }
161
+
162
+ function isTextFile(relativePath) {
163
+ return TEXT_EXTENSIONS.has(path.extname(relativePath).toLowerCase()) || relativePath.endsWith(".md");
164
+ }
165
+
166
+ function sanitizeContent(content) {
167
+ let next = content;
168
+ let maskedCount = 0;
169
+ for (const [pattern, replacement] of REDACTION_RULES) {
170
+ next = next.replace(pattern, () => {
171
+ maskedCount += 1;
172
+ return replacement;
173
+ });
174
+ }
175
+ next = next.replace(/\/Users\/[^/\s]+/g, "~").replace(/([A-Za-z]:\\Users\\)[^\\\s]+/g, "$1user");
176
+ return { content: next.trim(), maskedCount };
177
+ }
178
+
179
+ function buildExcerpt(content) {
180
+ const compact = content.trim();
181
+ return compact ? compact.slice(0, MAX_EXCERPT_CHARS) : null;
182
+ }
183
+
184
+ function inferSkill(relativePath, content) {
185
+ if (!relativePath.startsWith("skills/")) return null;
186
+ const segments = relativePath.split("/");
187
+ const basename = segments.at(-1) ?? relativePath;
188
+ const folder = segments[1] ?? basename.replace(/\.[^.]+$/, "");
189
+ const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
190
+ return {
191
+ id: slugify(folder),
192
+ name: heading || titleCaseFromSlug(folder),
193
+ entry: relativePath,
194
+ path: relativePath,
195
+ };
196
+ }
197
+
198
+ async function collectFiles(root, currentDir, shared, blocked, skills, stats) {
199
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
200
+ for (const entry of entries) {
201
+ const absolutePath = path.join(currentDir, entry.name);
202
+ const relativePath = normalizeRelativePath(root, absolutePath);
203
+ if (!relativePath) continue;
204
+
205
+ if (entry.isDirectory()) {
206
+ if (isBlockedFile(relativePath)) {
207
+ blocked.push(relativePath);
208
+ continue;
209
+ }
210
+ await collectFiles(root, absolutePath, shared, blocked, skills, stats);
211
+ continue;
212
+ }
213
+
214
+ stats.scanned_files += 1;
215
+ if (isBlockedFile(relativePath)) {
216
+ blocked.push(relativePath);
217
+ continue;
218
+ }
219
+ if (!isAllowedFile(relativePath)) {
220
+ continue;
221
+ }
222
+
223
+ const fileStat = await fs.stat(absolutePath);
224
+ const record = {
225
+ path: relativePath,
226
+ size: fileStat.size,
227
+ kind: "binary",
228
+ content_excerpt: null,
229
+ content_text: null,
230
+ masked_count: 0,
231
+ };
232
+
233
+ if (!isTextFile(relativePath) || fileStat.size > MAX_FILE_BYTES) {
234
+ shared.push(record);
235
+ continue;
236
+ }
237
+
238
+ const raw = await fs.readFile(absolutePath, "utf8");
239
+ const sanitized = sanitizeContent(raw);
240
+ record.kind = "text";
241
+ record.content_excerpt = buildExcerpt(sanitized.content);
242
+ record.content_text = sanitized.content || null;
243
+ record.masked_count = sanitized.maskedCount;
244
+ shared.push(record);
245
+ stats.masked_secrets_count += sanitized.maskedCount;
246
+
247
+ const skill = inferSkill(relativePath, sanitized.content);
248
+ if (skill && !skills.has(skill.id)) {
249
+ skills.set(skill.id, skill);
250
+ }
251
+ }
252
+ }
253
+
254
+ async function readExplicitReadme(readmePath) {
255
+ const resolvedPath = path.resolve(readmePath.trim());
256
+ const content = await fs.readFile(resolvedPath, "utf8");
257
+ return content.trim();
258
+ }
259
+
260
+ function deriveSummary(name, summary, readme, sharedCount) {
261
+ if (summary?.trim()) return summary.trim();
262
+ const normalized = readme.replace(/^#+\s*/gm, "").replace(/`/g, "").replace(/\[(.*?)\]\((.*?)\)/g, "$1").replace(/\s+/g, " ").trim();
263
+ return normalized ? normalized.slice(0, 180) : `${name} workspace publish with ${sharedCount} shared files.`;
264
+ }
265
+
266
+ async function buildPayload(options) {
267
+ const workspaceRoot = await resolveWorkspaceRoot(options.workspace);
268
+ const name = options.name?.trim() || titleCaseFromSlug(path.basename(workspaceRoot));
269
+ const version = options.version?.trim() || "0.1.0";
270
+ const license = options.license?.trim() || "MIT";
271
+ if (!ALLOWED_LICENSES.has(license)) throw new Error(`Unsupported license: ${license}`);
272
+ if (!SEMVER_RE.test(version)) throw new Error(`Invalid version: ${version}`);
273
+
274
+ const shared = [];
275
+ const blocked = [];
276
+ const skills = new Map();
277
+ const stats = { scanned_files: 0, shared_files: 0, blocked_files_count: 0, masked_secrets_count: 0 };
278
+
279
+ await collectFiles(workspaceRoot, workspaceRoot, shared, blocked, skills, stats);
280
+ shared.sort((a, b) => a.path.localeCompare(b.path));
281
+ blocked.sort((a, b) => a.localeCompare(b));
282
+
283
+ stats.shared_files = shared.length;
284
+ stats.blocked_files_count = blocked.length;
285
+
286
+ const tags = [...new Set(String(options.tags ?? "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean))];
287
+ const explicitReadmePath = options.readme?.trim();
288
+
289
+ let readme = "";
290
+ if (explicitReadmePath) {
291
+ readme = await readExplicitReadme(explicitReadmePath);
292
+ }
293
+
294
+ return {
295
+ workspaceRoot,
296
+ payload: {
297
+ lobster_slug: options.slug?.trim() || slugify(name),
298
+ name,
299
+ summary: deriveSummary(name, options.summary, readme, shared.length),
300
+ license,
301
+ version,
302
+ changelog: options.changelog?.trim() || "Initial workspace publish",
303
+ tags,
304
+ readme_markdown: readme || undefined,
305
+ source_repo: options.source_repo?.trim() || undefined,
306
+ source_commit: options.source_commit?.trim() || undefined,
307
+ publish_client: "clawlodge-cli/0.1.0",
308
+ workspace_files: shared,
309
+ blocked_files: blocked,
310
+ skills: Array.from(skills.values()),
311
+ settings: [
312
+ { key: "tags", value: tags },
313
+ { key: "blocked_files", value: blocked },
314
+ { key: "workspace_stats", value: stats },
315
+ ],
316
+ stats,
317
+ },
318
+ };
319
+ }
320
+
321
+ async function writePack(outputPath, payload) {
322
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
323
+ await fs.writeFile(outputPath, JSON.stringify(payload, null, 2), "utf8");
324
+ }
325
+
326
+ async function readConfig() {
327
+ try {
328
+ const raw = await fs.readFile(CONFIG_PATH, "utf8");
329
+ return JSON.parse(raw);
330
+ } catch {
331
+ return {};
332
+ }
333
+ }
334
+
335
+ async function writeConfig(config) {
336
+ await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
337
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf8", mode: 0o600 });
338
+ }
339
+
340
+ async function clearConfig() {
341
+ await fs.rm(CONFIG_PATH, { force: true });
342
+ }
343
+
344
+ function resolveOrigin(options, config = {}) {
345
+ return options.origin?.trim() || process.env.CLAWLODGE_ORIGIN || config.origin?.trim() || DEFAULT_ORIGIN;
346
+ }
347
+
348
+ function resolveToken(options, config = {}) {
349
+ return options.token?.trim() || process.env.CLAWLODGE_PAT || config.token?.trim() || "";
350
+ }
351
+
352
+ async function promptForToken(origin) {
353
+ if (!input.isTTY || !output.isTTY) {
354
+ throw new Error(`Missing PAT. Create one at ${origin}/settings, then run clawlodge login --token claw_pat_xxx or set CLAWLODGE_PAT.`);
355
+ }
356
+
357
+ console.log(`Create a PAT at ${origin}/settings and paste it below.`);
358
+ const rl = readline.createInterface({ input, output });
359
+ try {
360
+ return (await rl.question("PAT: ")).trim();
361
+ } finally {
362
+ rl.close();
363
+ }
364
+ }
365
+
366
+ async function requestJson(url, token, init = {}) {
367
+ const response = await fetch(url, {
368
+ ...init,
369
+ headers: {
370
+ "Content-Type": "application/json",
371
+ Authorization: `Bearer ${token}`,
372
+ ...(init.headers ?? {}),
373
+ },
374
+ });
375
+
376
+ const body = await response.json().catch(() => ({}));
377
+ if (!response.ok) {
378
+ throw new Error(body?.detail || `Request failed: ${response.status}`);
379
+ }
380
+ return body;
381
+ }
382
+
383
+ async function fetchPatProfile(origin, token) {
384
+ return requestJson(`${origin.replace(/\/$/, "")}/api/v1/me/pat`, token, { method: "GET" });
385
+ }
386
+
387
+ async function runLogin(options) {
388
+ const config = await readConfig();
389
+ const origin = resolveOrigin(options, config);
390
+ const token = options.token?.trim() || process.env.CLAWLODGE_PAT || await promptForToken(origin);
391
+ if (!token) {
392
+ throw new Error("PAT required");
393
+ }
394
+
395
+ const profile = await fetchPatProfile(origin, token);
396
+ await writeConfig({ origin, token });
397
+ console.log(JSON.stringify({
398
+ ok: true,
399
+ mode: "login",
400
+ origin,
401
+ user: profile.user,
402
+ active_token_prefix: profile.active_token_prefix,
403
+ config_path: CONFIG_PATH,
404
+ }, null, 2));
405
+ }
406
+
407
+ async function runWhoAmI(options) {
408
+ const config = await readConfig();
409
+ const origin = resolveOrigin(options, config);
410
+ const token = resolveToken(options, config);
411
+ if (!token) {
412
+ throw new Error(`Missing PAT. Create one at ${origin}/settings, then run clawlodge login.`);
413
+ }
414
+
415
+ const profile = await fetchPatProfile(origin, token);
416
+ console.log(JSON.stringify({
417
+ ok: true,
418
+ mode: "whoami",
419
+ origin,
420
+ user: profile.user,
421
+ active_token_prefix: profile.active_token_prefix,
422
+ active_token_last_used_at: profile.active_token_last_used_at,
423
+ }, null, 2));
424
+ }
425
+
426
+ async function runLogout() {
427
+ await clearConfig();
428
+ console.log(JSON.stringify({ ok: true, mode: "logout", config_path: CONFIG_PATH }, null, 2));
429
+ }
430
+
431
+ export async function runCli(argv = process.argv.slice(2)) {
432
+ const { command, options } = parseArgs(argv);
433
+ if (command === "help" || command === "--help" || command === "-h") {
434
+ printHelp();
435
+ return;
436
+ }
437
+
438
+ if (command === "login") {
439
+ await runLogin(options);
440
+ return;
441
+ }
442
+
443
+ if (command === "whoami") {
444
+ await runWhoAmI(options);
445
+ return;
446
+ }
447
+
448
+ if (command === "logout") {
449
+ await runLogout();
450
+ return;
451
+ }
452
+
453
+ const { workspaceRoot, payload } = await buildPayload(options);
454
+ const out = path.resolve(options.out ?? path.join(workspaceRoot, ".clawlodge", "workspace-publish.json"));
455
+
456
+ if (command === "pack") {
457
+ await writePack(out, payload);
458
+ console.log(JSON.stringify({ ok: true, mode: "pack", out, stats: payload.stats }, null, 2));
459
+ return;
460
+ }
461
+
462
+ if (command !== "publish") {
463
+ throw new Error(`Unsupported command: ${command}`);
464
+ }
465
+
466
+ const config = await readConfig();
467
+ const origin = resolveOrigin(options, config);
468
+ const token = resolveToken(options, config);
469
+ if (!token) {
470
+ throw new Error(`Missing PAT. Create one at ${origin}/settings, then run clawlodge login, pass --token, or set CLAWLODGE_PAT.`);
471
+ }
472
+
473
+ await writePack(out, payload);
474
+ const body = await requestJson(`${origin.replace(/\/$/, "")}/api/v1/workspace/publish`, token, {
475
+ method: "POST",
476
+ body: JSON.stringify(payload),
477
+ });
478
+
479
+ console.log(JSON.stringify({ ok: true, mode: "publish", out, origin, result: body }, null, 2));
480
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "clawlodge-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for packing and publishing OpenClaw configs to ClawLodge",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+ssh://git@github.com/memepilot/clawlodge.git",
8
+ "directory": "packages/clawlodge-cli"
9
+ },
10
+ "homepage": "https://clawlodge.com/publish",
11
+ "bugs": {
12
+ "url": "https://github.com/memepilot/clawlodge/issues"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "bin": {
18
+ "clawlodge": "bin/clawlodge.mjs"
19
+ },
20
+ "type": "module",
21
+ "files": [
22
+ "bin/clawlodge.mjs",
23
+ "lib/core.mjs",
24
+ "README.md"
25
+ ],
26
+ "license": "UNLICENSED",
27
+ "engines": {
28
+ "node": ">=18"
29
+ }
30
+ }