@bluelamp/secret-exec 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 +41 -0
- package/bin/bluelamp-secret.mjs +41 -0
- package/dist/index.js +218 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @bluelamp/secret-exec
|
|
2
|
+
|
|
3
|
+
> 🔒 **これは値を返さない口です。**
|
|
4
|
+
> 取れるのは「金庫にその名前が**在るか無いか**」と「値を**使えるか**(子プロセスが値を環境変数で受けて走れたか=子の終了コード)」だけです。
|
|
5
|
+
> 値を標準出力・標準エラー・戻り値・ファイル・コマンドの引数に出す口は、このパッケージには**ありません**。今後も作りません。
|
|
6
|
+
> 値を見たい用途(表示・コピー・ファイルへの書き出し)には使えません。それは設計上の意図です。
|
|
7
|
+
|
|
8
|
+
金庫(password-manager)に置いた値を、**子プロセスの環境変数にだけ**入れてコマンドを実行します。終了コードは子のものをそのまま返します。
|
|
9
|
+
|
|
10
|
+
## 使い方
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm i -g @bluelamp/secret-exec
|
|
14
|
+
|
|
15
|
+
bluelamp-secret exec <ENV名>=<金庫の名前> [<ENV名>=<金庫の名前> …] -- <コマンド> [引数…]
|
|
16
|
+
|
|
17
|
+
# 例: 金庫の「my-db」を PGURL に入れて psql を撃つ(シェルの展開が要るときは sh -c で包む)
|
|
18
|
+
bluelamp-secret exec PGURL=my-db -- sh -c 'psql "$PGURL" -c "select 1"'
|
|
19
|
+
|
|
20
|
+
# 例: 2 つ入れて python を走らせる
|
|
21
|
+
bluelamp-secret exec API_KEY=some-api DB_URL=some-db -- python3 job.py
|
|
22
|
+
|
|
23
|
+
# 例: 在るか・使えるかだけを確かめる(値の長さが 0 でなければ 0 で終わる)
|
|
24
|
+
bluelamp-secret exec X=my-db -- sh -c 'test -n "$X"'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- ログイン済みのトークン(`@bluelamp/cli-token` が読む `~/.musuhi/portal-token.enc`)で金庫を引きます。先に `bluelamp1` などでログインしてください。
|
|
28
|
+
- 接続先は `BLUELAMP_PORTAL_API_BASE_URL` > `PORTAL_URL` > 既定(`https://api.motohi.jp/api/bluelamp`)の順です。
|
|
29
|
+
- ENV 名は `^[A-Z_][A-Z0-9_]*$` の形だけ受けます。コマンドはシェルを通さずに起動します。
|
|
30
|
+
- 1 つでも引けなければ(無い・権限なし・未ログイン)、子を起こさずに「ENV 名・金庫の名前・理由」だけを標準エラーへ出して非 0 で終わります。HTTP の本文や例外の文言は出しません。
|
|
31
|
+
- 引いたことは金庫の監査に残ります(誰が・どの秘密を)。
|
|
32
|
+
- ⚠️ 子のコマンドが自分で値を印字すれば、それは出ます(`echo "$PGURL"` のように書かない)。
|
|
33
|
+
|
|
34
|
+
## 終了コード
|
|
35
|
+
|
|
36
|
+
| コード | 意味 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| 子の終了コード | 子が走った(シグナルで終わったときは 128+番号) |
|
|
39
|
+
| 1 | 未ログイン・金庫に無い・権限なし・ポータルに届かない(子は起こさない) |
|
|
40
|
+
| 2 | 引数の形が違う |
|
|
41
|
+
| 127 | コマンドを起動できなかった |
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bluelamp-secret — 金庫の値を子プロセスの環境変数にだけ入れてコマンドを走らせる(箱 n=2018 設計④′)。
|
|
4
|
+
* bluelamp-secret exec <ENV名>=<金庫の名前> [...] -- <コマンド> [引数…]
|
|
5
|
+
* 🔒 値を返さない口: 値は stdout・stderr・戻り値・ファイルに出ない(README の冒頭・src/index.ts の頭注)。
|
|
6
|
+
* 判定と組み立ては dist/index.js(unit テスト済み)。ここは実物の網・トークン・子プロセス・シグナルを繋ぐだけ。
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { constants as osConstants } from 'node:os';
|
|
10
|
+
import { tryReadCliToken } from '@bluelamp/cli-token';
|
|
11
|
+
import { runSecretExec } from '../dist/index.js';
|
|
12
|
+
|
|
13
|
+
// 端末の Ctrl-C は同じプロセスグループの子にも届く=親は受け流して子の終了を待つ。
|
|
14
|
+
// SIGTERM / SIGHUP は親だけに来うるので子へ回す。
|
|
15
|
+
const FORWARDED = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
16
|
+
|
|
17
|
+
runSecretExec(process.argv.slice(2), {
|
|
18
|
+
env: process.env,
|
|
19
|
+
loadToken: () => tryReadCliToken(),
|
|
20
|
+
fetch: (url, init) => globalThis.fetch(url, init),
|
|
21
|
+
spawn: (command, args, options) => spawn(command, args, options),
|
|
22
|
+
stderr: (text) => process.stderr.write(text),
|
|
23
|
+
signalNumbers: osConstants.signals,
|
|
24
|
+
onParentSignal: (forward) => {
|
|
25
|
+
const handlers = FORWARDED.map((sig) => {
|
|
26
|
+
const h = () => {
|
|
27
|
+
if (sig !== 'SIGINT') forward(sig);
|
|
28
|
+
};
|
|
29
|
+
process.on(sig, h);
|
|
30
|
+
return [sig, h];
|
|
31
|
+
});
|
|
32
|
+
return () => handlers.forEach(([sig, h]) => process.off(sig, h));
|
|
33
|
+
},
|
|
34
|
+
}).then(
|
|
35
|
+
(code) => process.exit(code),
|
|
36
|
+
() => {
|
|
37
|
+
// ⛔ 例外の文言は出さない(値や要求を引用しうる)
|
|
38
|
+
process.stderr.write('bluelamp-secret: 想定外の失敗で止まりました\n');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
},
|
|
41
|
+
);
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bluelamp/secret-exec — `bluelamp-secret exec`(箱 n=2018 設計④′)
|
|
3
|
+
*
|
|
4
|
+
* 🔒 これは「値を返さない口」である(実装より先に書いた線・README の冒頭と同じ)。
|
|
5
|
+
* 取れるのは「金庫にその名前が在るか無いか」と「値を使えるか(子プロセスが env で受けて走れたか=終了コード)」だけ。
|
|
6
|
+
* ⛔ 値を標準出力・標準エラー・戻り値・ファイル・argv・ログ・エラーの文言に出す口を作らない。
|
|
7
|
+
* ⛔ 値を返す関数を export しない(呼び手に値を渡す API を足したくなったら、それはこのパッケージの仕事ではない)。
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 🕳 塞いでいる穴: MCP の `get_secret` は値を返り値にしていたため、値が会話の転写に残った。
|
|
11
|
+
* その口を閉じると、値を Bash で使っていた手順(21 日で 235 回)が壊れる。⇒ 値を「見せずに使う」口をここに置く。
|
|
12
|
+
*
|
|
13
|
+
* 🔑 値の通り道は「金庫の HTTP 応答 → 子の env」の 1 本だけ。
|
|
14
|
+
* ⛔ HTTP の本文・fetch の例外をそのまま出さない(本文は値を、例外は要求を引用しうる)。
|
|
15
|
+
* 失敗は「どの ENV 名・どの金庫の名前が・なぜ」だけを返す。
|
|
16
|
+
* 🔑 パッケージの外へ出す API は無い(package.json の exports は package.json だけ)。
|
|
17
|
+
* 下の export は同じパッケージの bin と unit テストのためのもの。値を返す関数は export しない(`extractSecretValue` は非公開)。`runSecretExec` の戻り値は終了コードだけ。
|
|
18
|
+
* 🔑 使用の記録: `GET /vault/:service` は backend 側で監査 `get`(誰が・どの秘密を)を 1 行書く。
|
|
19
|
+
*
|
|
20
|
+
* 判定と組み立ては純粋関数(unit で直接撃つ)、網と子プロセスは `runSecretExec` の deps に寄せる。
|
|
21
|
+
*/
|
|
22
|
+
/** ENV 名として受ける形。⛔ これ以外(小文字・記号・先頭数字)は受けない */
|
|
23
|
+
export const ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
24
|
+
/** 既定のポータル API(bluelamp CLI の portal-api-client・TokenManager と同じ値) */
|
|
25
|
+
export const DEFAULT_PORTAL_API_BASE_URL = 'https://api.motohi.jp/api/bluelamp';
|
|
26
|
+
export const USAGE = '使い方: bluelamp-secret exec <ENV名>=<金庫の名前> [<ENV名>=<金庫の名前> …] -- <コマンド> [引数…]\n' +
|
|
27
|
+
' 金庫の値を子プロセスの環境変数にだけ入れて <コマンド> を実行し、その終了コードで終わります。\n' +
|
|
28
|
+
' 値は画面・引数・ログに出ません。シェルの展開が要るときは -- sh -c \'psql "$PGURL" -c "select 1"\' のように渡します。\n';
|
|
29
|
+
/** 引数を解析する(`process.argv.slice(2)` を受ける)。値はここに来ない=金庫の名前だけ */
|
|
30
|
+
export function parseExecArgs(argv) {
|
|
31
|
+
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help' || argv[0] === 'help') {
|
|
32
|
+
return { ok: false, help: true, error: '' };
|
|
33
|
+
}
|
|
34
|
+
if (argv[0] !== 'exec') {
|
|
35
|
+
return { ok: false, help: false, error: `不明な操作です(受けるのは exec だけ)` };
|
|
36
|
+
}
|
|
37
|
+
const sep = argv.indexOf('--');
|
|
38
|
+
if (sep === -1) {
|
|
39
|
+
return { ok: false, help: false, error: '`--` の後に実行するコマンドを書いてください' };
|
|
40
|
+
}
|
|
41
|
+
const specs = argv.slice(1, sep);
|
|
42
|
+
const command = argv[sep + 1];
|
|
43
|
+
if (command === undefined || command === '') {
|
|
44
|
+
return { ok: false, help: false, error: '`--` の後に実行するコマンドがありません' };
|
|
45
|
+
}
|
|
46
|
+
if (specs.length === 0) {
|
|
47
|
+
return { ok: false, help: false, error: '<ENV名>=<金庫の名前> を 1 つ以上書いてください' };
|
|
48
|
+
}
|
|
49
|
+
const bindings = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (const spec of specs) {
|
|
52
|
+
const eq = spec.indexOf('=');
|
|
53
|
+
// ⛔ 失敗の文言に spec 全体を引用しない(名前の位置に値を書き間違えた場合に値が出るため)。ENV 名の側だけ出す。
|
|
54
|
+
if (eq <= 0) {
|
|
55
|
+
return { ok: false, help: false, error: `<ENV名>=<金庫の名前> の形ではない引数があります(${bindings.length + 1} 番目)` };
|
|
56
|
+
}
|
|
57
|
+
const env = spec.slice(0, eq);
|
|
58
|
+
const name = spec.slice(eq + 1);
|
|
59
|
+
if (!ENV_NAME_PATTERN.test(env)) {
|
|
60
|
+
return { ok: false, help: false, error: `ENV 名は ^[A-Z_][A-Z0-9_]*$ の形だけ受けます(${bindings.length + 1} 番目)` };
|
|
61
|
+
}
|
|
62
|
+
if (name === '') {
|
|
63
|
+
return { ok: false, help: false, error: `${env}: 金庫の名前が空です` };
|
|
64
|
+
}
|
|
65
|
+
// `.`/`..` は URL の区間として解決され、金庫の外の経路を指してしまう(%2E も URL 側で同じに扱われる)
|
|
66
|
+
if (name === '.' || name === '..') {
|
|
67
|
+
return { ok: false, help: false, error: `${env}: 金庫の名前に . / .. は使えません` };
|
|
68
|
+
}
|
|
69
|
+
if (seen.has(env)) {
|
|
70
|
+
return { ok: false, help: false, error: `${env}: 同じ ENV 名が 2 回あります` };
|
|
71
|
+
}
|
|
72
|
+
seen.add(env);
|
|
73
|
+
bindings.push({ env, name });
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, bindings, command, args: argv.slice(sep + 2) };
|
|
76
|
+
}
|
|
77
|
+
/** 基底 URL(BLUELAMP_PORTAL_API_BASE_URL > PORTAL_URL > 既定。portal-api-client.cjs と同じ順) */
|
|
78
|
+
export function resolvePortalApiBaseUrl(env) {
|
|
79
|
+
const base = env.BLUELAMP_PORTAL_API_BASE_URL || env.PORTAL_URL || DEFAULT_PORTAL_API_BASE_URL;
|
|
80
|
+
return base.replace(/\/+$/, '');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 金庫の 1 件を引く URL。
|
|
84
|
+
* 🔑 vault の sub-app は内部で `/api/vault/...` に経路を切るが、bluelamp module ごと `/api/bluelamp` に
|
|
85
|
+
* 載っているので外から見える経路は `<base>/vault/...`(base は /api/bluelamp まで含む)。
|
|
86
|
+
* 🔬 実測(無認証): `/api/bluelamp/api/vault/x` → 404/`/api/bluelamp/vault/x` → 401。
|
|
87
|
+
* mcp-list/bluelamp/playwright/src/fillSecret.ts の VAULT_FILL_PATH と同じ結論。
|
|
88
|
+
*/
|
|
89
|
+
export function buildVaultUrl(baseUrl, name) {
|
|
90
|
+
return `${baseUrl}/vault/${encodeURIComponent(name)}`;
|
|
91
|
+
}
|
|
92
|
+
/** 子の env を合成する(親を書き換えない) */
|
|
93
|
+
export function composeChildEnv(parentEnv, values) {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const [k, v] of Object.entries(parentEnv))
|
|
96
|
+
if (typeof v === 'string')
|
|
97
|
+
out[k] = v;
|
|
98
|
+
for (const [k, v] of Object.entries(values))
|
|
99
|
+
out[k] = v;
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
/** 金庫の応答本文から値を取り出す。形が違えば null(⛔ 本文を呼び手へ返さない・値は runSecretExec の中で子の env へ渡すだけ) */
|
|
103
|
+
function extractSecretValue(body) {
|
|
104
|
+
if (!body || typeof body !== 'object')
|
|
105
|
+
return null;
|
|
106
|
+
const b = body;
|
|
107
|
+
if (b.success !== true || !b.data || typeof b.data !== 'object')
|
|
108
|
+
return null;
|
|
109
|
+
return typeof b.data.secret === 'string' ? b.data.secret : null;
|
|
110
|
+
}
|
|
111
|
+
/** HTTP の状態を理由の文言にする(⛔ 本文は使わない) */
|
|
112
|
+
export function describeVaultStatus(status) {
|
|
113
|
+
if (status === 401)
|
|
114
|
+
return '未ログインかトークンが無効です(bluelamp1 などでログインし直してください)';
|
|
115
|
+
if (status === 403)
|
|
116
|
+
return 'この秘密へのアクセス権がありません';
|
|
117
|
+
if (status === 404)
|
|
118
|
+
return '金庫に見つかりません';
|
|
119
|
+
return `金庫が HTTP ${status} を返しました`;
|
|
120
|
+
}
|
|
121
|
+
/** 子の終わり方を終了コードにする(シグナルは 128+番号=シェルと同じ) */
|
|
122
|
+
export function exitCodeOf(code, signal, signalNumbers) {
|
|
123
|
+
if (typeof code === 'number')
|
|
124
|
+
return code;
|
|
125
|
+
if (signal && typeof signalNumbers[signal] === 'number')
|
|
126
|
+
return 128 + signalNumbers[signal];
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
const PREFIX = 'bluelamp-secret: ';
|
|
130
|
+
/** 実行する。戻り値は終了コード */
|
|
131
|
+
export async function runSecretExec(argv, deps) {
|
|
132
|
+
const parsed = parseExecArgs(argv);
|
|
133
|
+
if (!parsed.ok) {
|
|
134
|
+
if (parsed.help) {
|
|
135
|
+
deps.stderr(USAGE);
|
|
136
|
+
return argv.length === 0 ? 2 : 0;
|
|
137
|
+
}
|
|
138
|
+
deps.stderr(`${PREFIX}${parsed.error}\n${USAGE}`);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
const token = deps.loadToken();
|
|
142
|
+
if (!token) {
|
|
143
|
+
deps.stderr(`${PREFIX}未ログインです(bluelamp1 などでログインしてから撃ってください)\n`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
const baseUrl = resolvePortalApiBaseUrl(deps.env);
|
|
147
|
+
const values = {};
|
|
148
|
+
// 🔑 全部そろってから子を起こす(一部だけ入った env で走らせない)
|
|
149
|
+
for (const { env, name } of parsed.bindings) {
|
|
150
|
+
let res;
|
|
151
|
+
try {
|
|
152
|
+
res = await deps.fetch(buildVaultUrl(baseUrl, name), {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
headers: { 'X-CLI-Token': token, Accept: 'application/json', 'User-Agent': 'bluelamp-secret/1' },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// ⛔ 例外の文言は出さない(要求 URL・ヘッダを引用する実装がある)
|
|
159
|
+
deps.stderr(`${PREFIX}${env}=${name}: ポータルに届きませんでした\n`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
deps.stderr(`${PREFIX}${env}=${name}: ${describeVaultStatus(res.status)}\n`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
let body;
|
|
167
|
+
try {
|
|
168
|
+
body = await res.json();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
deps.stderr(`${PREFIX}${env}=${name}: 金庫の応答を読めませんでした\n`);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
const value = extractSecretValue(body);
|
|
175
|
+
if (value === null) {
|
|
176
|
+
deps.stderr(`${PREFIX}${env}=${name}: 金庫の応答に値がありませんでした\n`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
values[env] = value;
|
|
180
|
+
}
|
|
181
|
+
const childEnv = composeChildEnv(deps.env, values);
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
let settled = false;
|
|
184
|
+
let detach = () => { };
|
|
185
|
+
const finish = (code) => {
|
|
186
|
+
if (settled)
|
|
187
|
+
return;
|
|
188
|
+
settled = true;
|
|
189
|
+
detach();
|
|
190
|
+
resolve(code);
|
|
191
|
+
};
|
|
192
|
+
let child;
|
|
193
|
+
try {
|
|
194
|
+
child = deps.spawn(parsed.command, parsed.args, { stdio: 'inherit', shell: false, env: childEnv });
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
const code = e?.code;
|
|
198
|
+
deps.stderr(`${PREFIX}コマンドを起動できませんでした: ${parsed.command}${code ? `(${code})` : ''}\n`);
|
|
199
|
+
finish(127);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (deps.onParentSignal) {
|
|
203
|
+
detach = deps.onParentSignal((signal) => {
|
|
204
|
+
try {
|
|
205
|
+
child.kill(signal);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// 既に終わっている
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
child.on('error', (err) => {
|
|
213
|
+
deps.stderr(`${PREFIX}コマンドを起動できませんでした: ${parsed.command}${err?.code ? `(${err.code})` : ''}\n`);
|
|
214
|
+
finish(127);
|
|
215
|
+
});
|
|
216
|
+
child.on('exit', (code, signal) => finish(exitCodeOf(code, signal, deps.signalNumbers)));
|
|
217
|
+
});
|
|
218
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bluelamp/secret-exec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "金庫の値を子プロセスの環境変数にだけ入れてコマンドを実行する(値を返さない口)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bluelamp",
|
|
7
|
+
"cli",
|
|
8
|
+
"secret"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "BlueLamp <dev@bluelamp.app>",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"bluelamp-secret": "./bin/bluelamp-secret.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"bin",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/mikoto-co-jp/mikoto-monorepo.git",
|
|
30
|
+
"directory": "packages/secret-exec"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
34
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
35
|
+
"tsc": "tsc --noEmit",
|
|
36
|
+
"prepublishOnly": "npm run build",
|
|
37
|
+
"test": "npm run build && node --test tests/*.test.mjs"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@bluelamp/cli-token": "1.0.2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.10.5",
|
|
44
|
+
"typescript": "^5.7.3"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
}
|
|
49
|
+
}
|