quilltap 4.7.0-dev → 4.7.0-dev.117
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 +38 -4
- package/bin/quilltap.js +63 -5
- package/lib/__tests__/qtap-uri.test.js +111 -0
- package/lib/completion/bash.template +1 -1
- package/lib/completion/fish.template +2 -1
- package/lib/completion/zsh.template +2 -1
- package/lib/docs-commands.js +423 -19
- package/lib/lock-helpers.js +242 -0
- package/lib/maintenance-commands.js +394 -0
- package/lib/native-modules.js +91 -32
- package/lib/qtap-uri.js +171 -0
- package/lib/theme-validation.js +37 -0
- package/package.json +3 -3
package/lib/native-modules.js
CHANGED
|
@@ -21,6 +21,84 @@ function resolveModuleDir(moduleName) {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Locate the SQLCipher binding (better-sqlite3-multiple-ciphers, aliased as
|
|
25
|
+
// better-sqlite3). Returns the absolute path to better_sqlite3.node, or null.
|
|
26
|
+
function betterSqlite3BindingPath() {
|
|
27
|
+
const modDir = resolveModuleDir('better-sqlite3-multiple-ciphers')
|
|
28
|
+
|| resolveModuleDir('better-sqlite3');
|
|
29
|
+
if (!modDir) return null;
|
|
30
|
+
return path.join(modDir, 'build', 'Release', 'better_sqlite3.node');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Read the Node ABI (NODE_MODULE_VERSION) a node-gyp/NAN addon was compiled
|
|
34
|
+
// against, by scanning for its `node_register_module_v<ABI>` export — a few-KB
|
|
35
|
+
// byte read, no dlopen, no rebuild. Returns the ABI as a string, or null when
|
|
36
|
+
// the symbol is absent (e.g. an N-API build, which is ABI-stable) or unreadable.
|
|
37
|
+
function readCompiledAbi(bindingPath) {
|
|
38
|
+
try {
|
|
39
|
+
const buf = require('fs').readFileSync(bindingPath);
|
|
40
|
+
const m = /node_register_module_v(\d+)/.exec(buf.toString('latin1'));
|
|
41
|
+
return m ? m[1] : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// True when the SQLCipher binding is missing or was built for a different Node
|
|
48
|
+
// ABI than the one we're running. Reads the compiled-for ABI straight from the
|
|
49
|
+
// binary; only falls back to an actual load probe if the symbol can't be read.
|
|
50
|
+
function betterSqlite3NeedsRebuild() {
|
|
51
|
+
const bindingPath = betterSqlite3BindingPath();
|
|
52
|
+
if (!bindingPath) return true; // unresolvable → needs (re)build
|
|
53
|
+
if (!require('fs').existsSync(bindingPath)) return true;
|
|
54
|
+
const compiledAbi = readCompiledAbi(bindingPath);
|
|
55
|
+
if (compiledAbi) return compiledAbi !== process.versions.modules;
|
|
56
|
+
// Symbol unreadable — fall back to the authoritative dlopen probe.
|
|
57
|
+
try {
|
|
58
|
+
require(bindingPath);
|
|
59
|
+
return false;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return !!(err.message && err.message.includes('NODE_MODULE_VERSION'));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Rebuild the named native modules against the current Node ABI. Prints a
|
|
66
|
+
// friendly notice rather than throwing; returns true on success, false on
|
|
67
|
+
// failure. Backfills node-pty's spawn-helper afterward.
|
|
68
|
+
function rebuildModules(moduleNames) {
|
|
69
|
+
console.log(` Rebuilding native modules for Node.js ${process.version}...`);
|
|
70
|
+
try {
|
|
71
|
+
execSync(`npm rebuild ${moduleNames.join(' ')}`, {
|
|
72
|
+
cwd: PACKAGE_DIR,
|
|
73
|
+
stdio: 'inherit',
|
|
74
|
+
});
|
|
75
|
+
console.log(' Done.');
|
|
76
|
+
console.log('');
|
|
77
|
+
reconcileNodePtySpawnHelper();
|
|
78
|
+
return true;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error('');
|
|
81
|
+
console.error(` Warning: Failed to rebuild native modules: ${err.message}`);
|
|
82
|
+
console.error(' Try running: npm rebuild --prefix ' + PACKAGE_DIR);
|
|
83
|
+
console.error('');
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fast pre-flight for the ONE ABI-fragile native module every DB path needs:
|
|
89
|
+
// better-sqlite3-multiple-ciphers (SQLCipher). sharp and node-pty are N-API and
|
|
90
|
+
// ABI-stable, so they can't hit this failure. Detects an ABI mismatch from the
|
|
91
|
+
// binary itself and rebuilds before anything tries to load it, so a Node upgrade
|
|
92
|
+
// self-heals instead of throwing. Cheap no-op when already healthy. Never throws.
|
|
93
|
+
function ensureDatabaseNativeModule() {
|
|
94
|
+
try {
|
|
95
|
+
if (!betterSqlite3NeedsRebuild()) return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return true; // detection hiccup — let the real load be the source of truth
|
|
98
|
+
}
|
|
99
|
+
return rebuildModules(['better-sqlite3-multiple-ciphers']);
|
|
100
|
+
}
|
|
101
|
+
|
|
24
102
|
// node-pty needs a `spawn-helper` executable beside the pty.node it loads, or
|
|
25
103
|
// pty.spawn() fails with `posix_spawnp failed`. An ABI rebuild lands a fresh
|
|
26
104
|
// build/Release/pty.node (which node-pty's loader prefers over prebuilds/) but
|
|
@@ -74,19 +152,9 @@ function ensureNativeModules() {
|
|
|
74
152
|
// Check better-sqlite3-multiple-ciphers (provides SQLCipher encryption support).
|
|
75
153
|
// The main app depends on this via an npm alias as 'better-sqlite3', so we must
|
|
76
154
|
// ensure the SQLCipher-capable version is available and link it as 'better-sqlite3'.
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|| resolveModuleDir('better-sqlite3');
|
|
81
|
-
if (!modDir) throw Object.assign(new Error('not found'), { code: 'MODULE_NOT_FOUND' });
|
|
82
|
-
const bindingsPath = path.join(modDir, 'build', 'Release', 'better_sqlite3.node');
|
|
83
|
-
require(bindingsPath);
|
|
84
|
-
} catch (err) {
|
|
85
|
-
if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
|
|
86
|
-
needsRebuild.push('better-sqlite3-multiple-ciphers');
|
|
87
|
-
} else if (err.code === 'MODULE_NOT_FOUND') {
|
|
88
|
-
needsRebuild.push('better-sqlite3-multiple-ciphers');
|
|
89
|
-
}
|
|
155
|
+
// This is the only ABI-fragile binding — detected straight from the binary.
|
|
156
|
+
if (betterSqlite3NeedsRebuild()) {
|
|
157
|
+
needsRebuild.push('better-sqlite3-multiple-ciphers');
|
|
90
158
|
}
|
|
91
159
|
|
|
92
160
|
// Check sharp: loads its native binding eagerly on require.
|
|
@@ -120,27 +188,18 @@ function ensureNativeModules() {
|
|
|
120
188
|
return true;
|
|
121
189
|
}
|
|
122
190
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
execSync(`npm rebuild ${needsRebuild.join(' ')}`, {
|
|
127
|
-
cwd: PACKAGE_DIR,
|
|
128
|
-
stdio: 'inherit',
|
|
129
|
-
});
|
|
130
|
-
console.log(' Done.');
|
|
131
|
-
console.log('');
|
|
132
|
-
reconcileNodePtySpawnHelper();
|
|
133
|
-
return true;
|
|
134
|
-
} catch (err) {
|
|
135
|
-
console.error('');
|
|
136
|
-
console.error(` Warning: Failed to rebuild native modules: ${err.message}`);
|
|
137
|
-
console.error(' Try running: npm rebuild --prefix ' + PACKAGE_DIR);
|
|
138
|
-
console.error('');
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
191
|
+
return rebuildModules(needsRebuild);
|
|
141
192
|
}
|
|
142
193
|
|
|
143
|
-
module.exports = {
|
|
194
|
+
module.exports = {
|
|
195
|
+
resolveModuleDir,
|
|
196
|
+
readCompiledAbi,
|
|
197
|
+
betterSqlite3NeedsRebuild,
|
|
198
|
+
ensureDatabaseNativeModule,
|
|
199
|
+
ensureNativeModules,
|
|
200
|
+
reconcileNodePtySpawnHelper,
|
|
201
|
+
PACKAGE_DIR,
|
|
202
|
+
};
|
|
144
203
|
|
|
145
204
|
// Allow this file to be invoked directly as a postinstall script:
|
|
146
205
|
// node lib/native-modules.js
|
package/lib/qtap-uri.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `qtap://` Document URI codec — CLI-local, dependency-free port.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the server codec at `lib/doc-edit/qtap-uri.ts` (same grammar, same
|
|
5
|
+
* encoding, same tests). Kept as its own small CommonJS module because the
|
|
6
|
+
* server module is not importable from the published CLI package. If the
|
|
7
|
+
* grammar changes, update BOTH and their tests.
|
|
8
|
+
*
|
|
9
|
+
* qtap://authority/path[#fragment][?query]
|
|
10
|
+
*
|
|
11
|
+
* Authority → { scope, mountPoint }:
|
|
12
|
+
* self → document_store, mountPoint 'self'
|
|
13
|
+
* project → project
|
|
14
|
+
* general → general
|
|
15
|
+
* else → document_store, mountPoint = the decoded authority (name or UUID)
|
|
16
|
+
*
|
|
17
|
+
* @module qtap-uri (CLI)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const QTAP_URI_SCHEME = 'qtap://';
|
|
23
|
+
const SELF_VAULT_TOKEN = 'self';
|
|
24
|
+
|
|
25
|
+
class QtapUriError extends Error {
|
|
26
|
+
constructor(message, code) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'QtapUriError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True iff the value starts with the qtap:// scheme (case-insensitive). */
|
|
34
|
+
function isQtapUri(s) {
|
|
35
|
+
return typeof s === 'string' && s.toLowerCase().startsWith(QTAP_URI_SCHEME);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeDecode(component) {
|
|
39
|
+
try {
|
|
40
|
+
return decodeURIComponent(component);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new QtapUriError(`Malformed percent-encoding in qtap:// URI segment: "${component}"`, 'MALFORMED');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseFragment(fragment) {
|
|
47
|
+
if (fragment === '') return {};
|
|
48
|
+
const colonIdx = fragment.lastIndexOf(':');
|
|
49
|
+
if (colonIdx === -1) {
|
|
50
|
+
return { heading: safeDecode(fragment) };
|
|
51
|
+
}
|
|
52
|
+
const headingPart = fragment.slice(0, colonIdx);
|
|
53
|
+
const levelPart = fragment.slice(colonIdx + 1);
|
|
54
|
+
if (!/^[0-9]+$/.test(levelPart)) {
|
|
55
|
+
throw new QtapUriError(`Invalid heading level "${levelPart}" in qtap:// fragment; expected an integer 1–6.`, 'BAD_LEVEL');
|
|
56
|
+
}
|
|
57
|
+
const level = parseInt(levelPart, 10);
|
|
58
|
+
if (level < 1 || level > 6) {
|
|
59
|
+
throw new QtapUriError(`Heading level ${level} out of range in qtap:// fragment; expected 1–6.`, 'BAD_LEVEL');
|
|
60
|
+
}
|
|
61
|
+
return { heading: safeDecode(headingPart), level };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseQuery(query) {
|
|
65
|
+
if (query === '') return undefined;
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const pair of query.split('&')) {
|
|
68
|
+
if (pair === '') continue;
|
|
69
|
+
const eq = pair.indexOf('=');
|
|
70
|
+
if (eq === -1) out[safeDecode(pair)] = '';
|
|
71
|
+
else out[safeDecode(pair.slice(0, eq))] = safeDecode(pair.slice(eq + 1));
|
|
72
|
+
}
|
|
73
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Parse a qtap:// URI into { scope, mountPoint?, path, heading?, level?, query? }. */
|
|
77
|
+
function parseQtapUri(uri) {
|
|
78
|
+
if (!isQtapUri(uri)) {
|
|
79
|
+
throw new QtapUriError(`Not a qtap:// URI: ${typeof uri === 'string' ? `"${uri}"` : typeof uri}`, 'NOT_A_QTAP_URI');
|
|
80
|
+
}
|
|
81
|
+
let rest = uri.slice(QTAP_URI_SCHEME.length);
|
|
82
|
+
|
|
83
|
+
let query;
|
|
84
|
+
const qIdx = rest.indexOf('?');
|
|
85
|
+
if (qIdx !== -1) {
|
|
86
|
+
query = parseQuery(rest.slice(qIdx + 1));
|
|
87
|
+
rest = rest.slice(0, qIdx);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let heading;
|
|
91
|
+
let level;
|
|
92
|
+
const hashIdx = rest.indexOf('#');
|
|
93
|
+
if (hashIdx !== -1) {
|
|
94
|
+
const frag = parseFragment(rest.slice(hashIdx + 1));
|
|
95
|
+
heading = frag.heading;
|
|
96
|
+
level = frag.level;
|
|
97
|
+
rest = rest.slice(0, hashIdx);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const slashIdx = rest.indexOf('/');
|
|
101
|
+
const rawAuthority = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
|
|
102
|
+
const rawPath = slashIdx === -1 ? '' : rest.slice(slashIdx + 1);
|
|
103
|
+
|
|
104
|
+
const authority = safeDecode(rawAuthority);
|
|
105
|
+
if (authority === '') {
|
|
106
|
+
throw new QtapUriError('qtap:// URI has an empty authority.', 'EMPTY_AUTHORITY');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const path = rawPath === '' ? '' : rawPath.split('/').map((seg) => safeDecode(seg)).join('/');
|
|
110
|
+
|
|
111
|
+
const lower = authority.toLowerCase();
|
|
112
|
+
const parts = { scope: 'document_store', path };
|
|
113
|
+
if (lower === SELF_VAULT_TOKEN) {
|
|
114
|
+
parts.mountPoint = SELF_VAULT_TOKEN;
|
|
115
|
+
} else if (lower === 'project') {
|
|
116
|
+
parts.scope = 'project';
|
|
117
|
+
} else if (lower === 'general') {
|
|
118
|
+
parts.scope = 'general';
|
|
119
|
+
} else {
|
|
120
|
+
parts.mountPoint = authority;
|
|
121
|
+
}
|
|
122
|
+
if (heading !== undefined) parts.heading = heading;
|
|
123
|
+
if (level !== undefined) parts.level = level;
|
|
124
|
+
if (query !== undefined) parts.query = query;
|
|
125
|
+
return parts;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function encodeAuthority(parts) {
|
|
129
|
+
if (parts.scope === 'project') return 'project';
|
|
130
|
+
if (parts.scope === 'general') return 'general';
|
|
131
|
+
const mp = parts.mountPoint || '';
|
|
132
|
+
if (mp.toLowerCase() === SELF_VAULT_TOKEN) return 'self';
|
|
133
|
+
return encodeURIComponent(mp);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function encodePath(path) {
|
|
137
|
+
if (!path) return '';
|
|
138
|
+
return path.split('/').map((seg) => encodeURIComponent(seg)).join('/');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Inverse of parseQtapUri — always emits canonical encoded form (':' → %3A). */
|
|
142
|
+
function formatQtapUri(parts) {
|
|
143
|
+
const authority = encodeAuthority(parts);
|
|
144
|
+
let out = `${QTAP_URI_SCHEME}${authority}/${encodePath(parts.path || '')}`;
|
|
145
|
+
if (parts.heading !== undefined && parts.heading !== '') {
|
|
146
|
+
out += `#${encodeURIComponent(parts.heading)}`;
|
|
147
|
+
if (parts.level !== undefined) out += `:${parts.level}`;
|
|
148
|
+
}
|
|
149
|
+
if (parts.query && Object.keys(parts.query).length > 0) {
|
|
150
|
+
const q = Object.entries(parts.query)
|
|
151
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
152
|
+
.join('&');
|
|
153
|
+
out += `?${q}`;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Build a document-store URI for a store name/UUID and relative path. */
|
|
159
|
+
function formatDocStoreUri(authority, path) {
|
|
160
|
+
return formatQtapUri({ scope: 'document_store', mountPoint: authority, path: path || '' });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
QTAP_URI_SCHEME,
|
|
165
|
+
SELF_VAULT_TOKEN,
|
|
166
|
+
QtapUriError,
|
|
167
|
+
isQtapUri,
|
|
168
|
+
parseQtapUri,
|
|
169
|
+
formatQtapUri,
|
|
170
|
+
formatDocStoreUri,
|
|
171
|
+
};
|
package/lib/theme-validation.js
CHANGED
|
@@ -36,6 +36,12 @@ const BLOCKED_EXTENSIONS = new Set([
|
|
|
36
36
|
// Theme ID must be lowercase alphanumeric with hyphens
|
|
37
37
|
const THEME_ID_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
38
38
|
|
|
39
|
+
// Icon override names: lowercase kebab-case (mirrors the app's IconName contract)
|
|
40
|
+
const ICON_NAME_REGEX = /^[a-z][a-z0-9-]*$/;
|
|
41
|
+
|
|
42
|
+
// Allowed file extensions for icon override assets
|
|
43
|
+
const ICON_OVERRIDE_EXTENSIONS = ['.svg', '.webp'];
|
|
44
|
+
|
|
39
45
|
// Required color keys in a palette
|
|
40
46
|
const REQUIRED_COLOR_KEYS = [
|
|
41
47
|
'background', 'foreground', 'primary', 'primaryForeground',
|
|
@@ -124,6 +130,35 @@ function validateManifest(manifest) {
|
|
|
124
130
|
}
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
// Icons validation (per-icon override map: name -> bundle-relative asset path).
|
|
134
|
+
// The canonical icon-name list lives in the app and cannot be imported here, so
|
|
135
|
+
// this is a soft check: structure, asset extension, and traversal safety only.
|
|
136
|
+
if (manifest.icons !== undefined) {
|
|
137
|
+
if (typeof manifest.icons !== 'object' || manifest.icons === null || Array.isArray(manifest.icons)) {
|
|
138
|
+
errors.push('icons must be an object mapping icon names to asset paths');
|
|
139
|
+
} else {
|
|
140
|
+
for (const [iconName, assetPath] of Object.entries(manifest.icons)) {
|
|
141
|
+
if (!ICON_NAME_REGEX.test(iconName)) {
|
|
142
|
+
// Soft warning: the app validates the actual name list, but a malformed
|
|
143
|
+
// name here is almost certainly a typo that will silently never match.
|
|
144
|
+
warnings.push(`icons.${iconName} is not a valid icon name (expected lowercase kebab-case)`);
|
|
145
|
+
}
|
|
146
|
+
if (typeof assetPath !== 'string' || assetPath.length === 0) {
|
|
147
|
+
errors.push(`icons.${iconName} must be a non-empty string asset path`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (assetPath.includes('..') || path.isAbsolute(assetPath)) {
|
|
151
|
+
errors.push(`icons.${iconName} has an unsafe asset path: ${assetPath}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const ext = path.extname(assetPath).toLowerCase();
|
|
155
|
+
if (!ICON_OVERRIDE_EXTENSIONS.includes(ext)) {
|
|
156
|
+
errors.push(`icons.${iconName} must point to a .svg or .webp file (got "${assetPath}")`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
127
162
|
return { valid: errors.length === 0, errors, warnings };
|
|
128
163
|
}
|
|
129
164
|
|
|
@@ -383,4 +418,6 @@ module.exports = {
|
|
|
383
418
|
ALLOWED_EXTENSIONS,
|
|
384
419
|
BLOCKED_EXTENSIONS,
|
|
385
420
|
THEME_ID_REGEX,
|
|
421
|
+
ICON_NAME_REGEX,
|
|
422
|
+
ICON_OVERRIDE_EXTENSIONS,
|
|
386
423
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quilltap",
|
|
3
|
-
"version": "4.7.0-dev",
|
|
3
|
+
"version": "4.7.0-dev.117",
|
|
4
4
|
"description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Charles Sebold",
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@napi-rs/canvas": "^0.1.100",
|
|
40
|
-
"better-sqlite3-multiple-ciphers": "^12.
|
|
40
|
+
"better-sqlite3-multiple-ciphers": "^12.11.1",
|
|
41
41
|
"node-pty": "^1.1.0",
|
|
42
42
|
"sharp": "^0.34.5",
|
|
43
43
|
"tar": "^7.5.16",
|
|
44
|
-
"yauzl": "^3.
|
|
44
|
+
"yauzl": "^3.4.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=24.0.0"
|