cchost 0.4.4 → 0.5.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/CHANGELOG.md +64 -0
- package/README.md +88 -43
- package/lib/banner.js +3 -1
- package/lib/cli.js +220 -98
- package/lib/color.js +3 -1
- package/lib/copy.js +31 -29
- package/lib/doctor.js +201 -68
- package/lib/env.js +65 -9
- package/lib/profiles.js +47 -14
- package/lib/share.js +63 -6
- package/lib/spawn.js +2 -1
- package/package.json +3 -2
package/lib/env.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// an API key and is treated with the same care as `.credentials.json`: it is
|
|
9
9
|
// never migrated, never linked, and written with user-only file permissions.
|
|
10
10
|
//
|
|
11
|
-
// Endpoint profiles are created and edited through `
|
|
11
|
+
// Endpoint profiles are created and edited through `cchost add <profile> --custom`;
|
|
12
12
|
// this module supplies the configuration logic that command delegates to.
|
|
13
13
|
|
|
14
14
|
const fs = require('fs');
|
|
@@ -21,6 +21,26 @@ const ENV_FILE = '.cc-env.json';
|
|
|
21
21
|
// Persisted fields, in canonical serialization order.
|
|
22
22
|
const FIELDS = ['base_url', 'auth_token', 'model', 'opus', 'sonnet', 'haiku', 'subagent'];
|
|
23
23
|
const TIER_FIELDS = ['opus', 'sonnet', 'haiku', 'subagent'];
|
|
24
|
+
// Fields whose value must be a string if present. Optional fields are ignored
|
|
25
|
+
// (treated as absent) when hand-edited to a non-string value.
|
|
26
|
+
const STRICT_STRING_FIELDS = ['base_url', 'auth_token'];
|
|
27
|
+
|
|
28
|
+
// Environment variables Claude Code reads for authentication. If any of these
|
|
29
|
+
// are inherited by a subscription profile, they can silently override the
|
|
30
|
+
// profile's own OAuth login (and billing).
|
|
31
|
+
const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
|
32
|
+
|
|
33
|
+
function authEnvConflicts(env = process.env) {
|
|
34
|
+
return AUTH_ENV_VARS.filter((key) => env[key]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Endpoint profiles must not inherit the parent's Anthropic auth/endpoint
|
|
38
|
+
// settings. Keep only the values buildEnvVars() set for this profile.
|
|
39
|
+
const ENDPOINT_ENV_VARS = [...AUTH_ENV_VARS, 'ANTHROPIC_BASE_URL'];
|
|
40
|
+
|
|
41
|
+
function endpointEnvToStrip(profileEnv, env = process.env) {
|
|
42
|
+
return ENDPOINT_ENV_VARS.filter((key) => env[key] && !profileEnv[key]);
|
|
43
|
+
}
|
|
24
44
|
|
|
25
45
|
// File field -> env var(s) injected into the spawned child.
|
|
26
46
|
const TIER_ENV = {
|
|
@@ -80,6 +100,20 @@ function readEnvConfig(profileDir) {
|
|
|
80
100
|
e.malformed = true;
|
|
81
101
|
throw e;
|
|
82
102
|
}
|
|
103
|
+
for (const key of STRICT_STRING_FIELDS) {
|
|
104
|
+
if (data[key] == null) continue;
|
|
105
|
+
if (typeof data[key] !== 'string') {
|
|
106
|
+
const e = stateError(`${ENV_FILE} is malformed: field "${key}" must be a string.`);
|
|
107
|
+
e.malformed = true;
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Optional fields are best-effort: a hand-edited non-string value must not
|
|
112
|
+
// make the whole profile unusable. Treat it as absent instead.
|
|
113
|
+
for (const key of FIELDS) {
|
|
114
|
+
if (STRICT_STRING_FIELDS.includes(key)) continue;
|
|
115
|
+
if (data[key] != null && typeof data[key] !== 'string') delete data[key];
|
|
116
|
+
}
|
|
83
117
|
return data;
|
|
84
118
|
}
|
|
85
119
|
|
|
@@ -129,9 +163,27 @@ function writeEnvConfig(profileDir, config) {
|
|
|
129
163
|
const target = envFilePath(profileDir);
|
|
130
164
|
const tmp = path.join(profileDir, `${ENV_FILE}.tmp-${process.pid}`);
|
|
131
165
|
// Write to a temp file then rename, so a crash never leaves a partial file.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
166
|
+
// The temp file is created 0600 so an orphaned temp cannot expose the token.
|
|
167
|
+
// Permissions are locked down again after the rename: restricting the temp
|
|
168
|
+
// file's ACL first makes the subsequent rename fail with EPERM on Windows.
|
|
169
|
+
// Remove any stale temp left by pid reuse, then create with O_EXCL so a race
|
|
170
|
+
// cannot make us write the token into a file with an inherited, looser mode.
|
|
171
|
+
fs.rmSync(tmp, { force: true });
|
|
172
|
+
try {
|
|
173
|
+
fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', {
|
|
174
|
+
mode: 0o600,
|
|
175
|
+
flag: 'wx',
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err.code !== 'EEXIST') {
|
|
179
|
+
try {
|
|
180
|
+
fs.unlinkSync(tmp);
|
|
181
|
+
} catch {
|
|
182
|
+
// best effort cleanup
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
135
187
|
try {
|
|
136
188
|
fs.renameSync(tmp, target);
|
|
137
189
|
} catch (err) {
|
|
@@ -173,7 +225,7 @@ function hostOf(url) {
|
|
|
173
225
|
}
|
|
174
226
|
}
|
|
175
227
|
|
|
176
|
-
// Endpoint summary for `
|
|
228
|
+
// Endpoint summary for `cchost list`. Returns the base-url host, 'subscription'
|
|
177
229
|
// (no endpoint config), or '(invalid config)' for a malformed file — never
|
|
178
230
|
// throws, so a single bad profile cannot break the whole table.
|
|
179
231
|
function endpointSummary(profileDir) {
|
|
@@ -382,7 +434,7 @@ async function runWizard(name, existing, io) {
|
|
|
382
434
|
// Command orchestration (called from cli.js)
|
|
383
435
|
// ---------------------------------------------------------------------------
|
|
384
436
|
|
|
385
|
-
// Configures a profile's custom endpoint. Called by `
|
|
437
|
+
// Configures a profile's custom endpoint. Called by `cchost add <profile> --custom`
|
|
386
438
|
// (both for first-time creation and for editing an existing endpoint profile).
|
|
387
439
|
// `provided` is a partial config of fields supplied via flags.
|
|
388
440
|
// `opts.io` injects wizard IO (tests); `opts.isTTY` overrides TTY detection.
|
|
@@ -391,14 +443,14 @@ async function runWizard(name, existing, io) {
|
|
|
391
443
|
// wizard was cancelled, so the caller can clean up a freshly-created profile.
|
|
392
444
|
async function runConfigure(name, profileDir, provided, opts = {}) {
|
|
393
445
|
if (!fs.existsSync(profileDir)) {
|
|
394
|
-
throw stateError(`Profile "${name}" does not exist. Create it with:
|
|
446
|
+
throw stateError(`Profile "${name}" does not exist. Create it with: cchost add ${name}`);
|
|
395
447
|
}
|
|
396
448
|
if (hasOAuthState(profileDir)) {
|
|
397
449
|
throw stateError(
|
|
398
450
|
`Profile '${name}' has OAuth credentials and cannot be converted to a custom endpoint.\n` +
|
|
399
451
|
'A profile is born subscription or endpoint and stays that way.\n' +
|
|
400
452
|
'Create a fresh endpoint profile instead:\n' +
|
|
401
|
-
'
|
|
453
|
+
' cchost add <new-name> --custom',
|
|
402
454
|
);
|
|
403
455
|
}
|
|
404
456
|
|
|
@@ -434,7 +486,7 @@ async function runConfigure(name, profileDir, provided, opts = {}) {
|
|
|
434
486
|
const isTTY = opts.isTTY != null ? opts.isTTY : process.stdin.isTTY;
|
|
435
487
|
if (!isTTY) {
|
|
436
488
|
throw usageError(
|
|
437
|
-
'
|
|
489
|
+
'cchost add --custom requires either endpoint flags or an interactive terminal.',
|
|
438
490
|
);
|
|
439
491
|
}
|
|
440
492
|
io = defaultWizardIo();
|
|
@@ -455,6 +507,10 @@ module.exports = {
|
|
|
455
507
|
ENV_FILE,
|
|
456
508
|
FIELDS,
|
|
457
509
|
TIER_FIELDS,
|
|
510
|
+
AUTH_ENV_VARS,
|
|
511
|
+
authEnvConflicts,
|
|
512
|
+
ENDPOINT_ENV_VARS,
|
|
513
|
+
endpointEnvToStrip,
|
|
458
514
|
envFilePath,
|
|
459
515
|
hasEnvConfig,
|
|
460
516
|
readEnvConfig,
|
package/lib/profiles.js
CHANGED
|
@@ -102,20 +102,30 @@ function addProfile(name) {
|
|
|
102
102
|
return { dir };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
// Single read/parse of a profile's .claude.json. Missing or malformed JSON is
|
|
106
|
+
// treated as absent state so one bad profile cannot break `cchost list`.
|
|
107
|
+
function readClaudeJson(dir) {
|
|
106
108
|
try {
|
|
107
109
|
const raw = fs.readFileSync(path.join(dir, '.claude.json'), 'utf8');
|
|
108
|
-
if (!raw.trim()) return
|
|
110
|
+
if (!raw.trim()) return null;
|
|
109
111
|
const data = JSON.parse(raw);
|
|
110
|
-
|
|
111
|
-
return data.oauthAccount.emailAddress;
|
|
112
|
-
}
|
|
113
|
-
return '';
|
|
112
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
|
|
114
113
|
} catch {
|
|
115
|
-
return
|
|
114
|
+
return null;
|
|
116
115
|
}
|
|
117
116
|
}
|
|
118
117
|
|
|
118
|
+
function emailFromClaude(data) {
|
|
119
|
+
if (data && data.oauthAccount && typeof data.oauthAccount.emailAddress === 'string') {
|
|
120
|
+
return data.oauthAccount.emailAddress;
|
|
121
|
+
}
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readEmail(dir) {
|
|
126
|
+
return emailFromClaude(readClaudeJson(dir));
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
function hasCredentials(dir) {
|
|
120
130
|
try {
|
|
121
131
|
return fs.existsSync(path.join(dir, '.credentials.json'));
|
|
@@ -124,7 +134,33 @@ function hasCredentials(dir) {
|
|
|
124
134
|
}
|
|
125
135
|
}
|
|
126
136
|
|
|
127
|
-
|
|
137
|
+
// Where this profile's Claude Code credentials live:
|
|
138
|
+
// 'file' — .credentials.json in the profile (Windows/Linux)
|
|
139
|
+
// 'keychain' — inferred from .claude.json on macOS; the token is in a
|
|
140
|
+
// Keychain entry namespaced by the profile's exact path
|
|
141
|
+
// 'none' — no sign of a login
|
|
142
|
+
function credentialSourceFrom(hasFile, data, platform) {
|
|
143
|
+
if (hasFile) return 'file';
|
|
144
|
+
if (platform === 'darwin' && data && data.oauthAccount != null) return 'keychain';
|
|
145
|
+
return 'none';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function credentialSource(dir, platform = process.platform) {
|
|
149
|
+
return credentialSourceFrom(hasCredentials(dir), readClaudeJson(dir), platform);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Everything `cchost list` needs about one profile, read from one parse.
|
|
153
|
+
function readProfile(dir, platform = process.platform) {
|
|
154
|
+
const data = readClaudeJson(dir);
|
|
155
|
+
const credentialSource = credentialSourceFrom(hasCredentials(dir), data, platform);
|
|
156
|
+
return {
|
|
157
|
+
loggedIn: credentialSource !== 'none',
|
|
158
|
+
credentialSource,
|
|
159
|
+
email: emailFromClaude(data),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function listProfiles(platform = process.platform) {
|
|
128
164
|
const base = baseDir();
|
|
129
165
|
if (!fs.existsSync(base)) return [];
|
|
130
166
|
let entries;
|
|
@@ -138,12 +174,7 @@ function listProfiles() {
|
|
|
138
174
|
if (!ent.isDirectory()) continue;
|
|
139
175
|
const name = ent.name;
|
|
140
176
|
const dir = path.join(base, name);
|
|
141
|
-
out.push({
|
|
142
|
-
name,
|
|
143
|
-
dir,
|
|
144
|
-
loggedIn: hasCredentials(dir),
|
|
145
|
-
email: readEmail(dir),
|
|
146
|
-
});
|
|
177
|
+
out.push({ name, dir, ...readProfile(dir, platform) });
|
|
147
178
|
}
|
|
148
179
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
149
180
|
return out;
|
|
@@ -159,6 +190,8 @@ module.exports = {
|
|
|
159
190
|
ensureProfileDir,
|
|
160
191
|
readEmail,
|
|
161
192
|
hasCredentials,
|
|
193
|
+
credentialSource,
|
|
194
|
+
readProfile,
|
|
162
195
|
listProfiles,
|
|
163
196
|
addProfile,
|
|
164
197
|
};
|
package/lib/share.js
CHANGED
|
@@ -180,6 +180,30 @@ function createResourceLink(target, link, resource) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Where one resource of a profile currently stands. Returns one of:
|
|
184
|
+
// 'shared' — linked to ~/.claude/ (symlink, junction, or hard link)
|
|
185
|
+
// 'private' — a real file/dir with content, unique to this profile
|
|
186
|
+
// 'empty' — a real but empty file/dir (link would replace it)
|
|
187
|
+
// 'missing' — nothing at the path
|
|
188
|
+
// 'foreign-link' — a symlink pointing somewhere other than ~/.claude/
|
|
189
|
+
function resourceState(profileDir, resource) {
|
|
190
|
+
const p = path.join(profileDir, resource);
|
|
191
|
+
if (isSharedResource(profileDir, resource)) return 'shared';
|
|
192
|
+
if (isLink(p)) return 'foreign-link';
|
|
193
|
+
let st;
|
|
194
|
+
try {
|
|
195
|
+
st = fs.statSync(p);
|
|
196
|
+
} catch {
|
|
197
|
+
return 'missing';
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const hasContent = st.isDirectory() ? fs.readdirSync(p).length > 0 : st.size > 0;
|
|
201
|
+
return hasContent ? 'private' : 'empty';
|
|
202
|
+
} catch {
|
|
203
|
+
return 'private';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
183
207
|
// Link each requested resource of a profile into the shared pool (~/.claude/).
|
|
184
208
|
// Each resource is attempted independently — one failure doesn't abort the
|
|
185
209
|
// rest. Returns [{ resource, status, ... }]; status ∈
|
|
@@ -188,18 +212,21 @@ function createResourceLink(target, link, resource) {
|
|
|
188
212
|
// 'refused' is the v0.4.1 behavior when the profile holds private content at
|
|
189
213
|
// the resource path: we do NOT auto-move it into the pool (was the v0.4.0
|
|
190
214
|
// behavior for dirs). The user must move/remove it manually.
|
|
215
|
+
//
|
|
216
|
+
// opts.dryRun reports the same statuses without touching the filesystem.
|
|
191
217
|
function shareLink(profileName, opts = {}) {
|
|
218
|
+
const dryRun = Boolean(opts.dryRun);
|
|
192
219
|
profiles.validateProfileName(profileName);
|
|
193
220
|
const pdir = profiles.profileDir(profileName);
|
|
194
221
|
if (!fs.existsSync(pdir)) {
|
|
195
222
|
const err = new Error(
|
|
196
|
-
`Profile "${profileName}" does not exist. Create it with \`
|
|
223
|
+
`Profile "${profileName}" does not exist. Create it with \`cchost add ${profileName}\` first.`,
|
|
197
224
|
);
|
|
198
225
|
err.exitCode = 1;
|
|
199
226
|
throw err;
|
|
200
227
|
}
|
|
201
228
|
const subset = resolveSubset(opts);
|
|
202
|
-
ensurePoolResources(subset);
|
|
229
|
+
if (!dryRun) ensurePoolResources(subset);
|
|
203
230
|
const pool = poolDir();
|
|
204
231
|
const legacyPool = profiles.sharedDir();
|
|
205
232
|
const results = [];
|
|
@@ -217,13 +244,22 @@ function shareLink(profileName, opts = {}) {
|
|
|
217
244
|
}
|
|
218
245
|
if (resolved === path.join(legacyPool, resource)) {
|
|
219
246
|
// Legacy v0.3 pointer — silently relink to the new pool location.
|
|
247
|
+
if (dryRun) {
|
|
248
|
+
results.push({ resource, status: 'linked', relinkedFromLegacy: true });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
220
251
|
removeLink(link);
|
|
221
252
|
const r = createResourceLink(target, link, resource);
|
|
222
253
|
results.push({ resource, status: 'linked', relinkedFromLegacy: true, kind: r.kind });
|
|
223
254
|
continue;
|
|
224
255
|
}
|
|
225
|
-
// Points somewhere unexpected — don't touch it
|
|
226
|
-
|
|
256
|
+
// Points somewhere unexpected — don't touch it, but report it as not
|
|
257
|
+
// shared so callers don't claim the resource is linked.
|
|
258
|
+
results.push({
|
|
259
|
+
resource,
|
|
260
|
+
status: 'foreign-link',
|
|
261
|
+
message: `symlink points to ${resolved}, not ${target} — left untouched`,
|
|
262
|
+
});
|
|
227
263
|
continue;
|
|
228
264
|
}
|
|
229
265
|
|
|
@@ -246,14 +282,20 @@ function shareLink(profileName, opts = {}) {
|
|
|
246
282
|
status: 'refused',
|
|
247
283
|
message:
|
|
248
284
|
`private content exists at ${link} — move or remove it manually, ` +
|
|
249
|
-
`then re-run \`
|
|
285
|
+
`then re-run \`cchost link ${profileName}\``,
|
|
250
286
|
});
|
|
251
287
|
continue;
|
|
252
288
|
}
|
|
253
289
|
// Empty private dir — safe to remove and replace with a symlink.
|
|
254
|
-
|
|
290
|
+
if (!dryRun) {
|
|
291
|
+
try { fs.rmdirSync(link); } catch { /* fall through; create will fail clearly */ }
|
|
292
|
+
}
|
|
255
293
|
}
|
|
256
294
|
|
|
295
|
+
if (dryRun) {
|
|
296
|
+
results.push({ resource, status: 'linked' });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
257
299
|
const r = createResourceLink(target, link, resource);
|
|
258
300
|
results.push({ resource, status: 'linked', kind: r.kind });
|
|
259
301
|
} catch (err) {
|
|
@@ -268,7 +310,12 @@ function shareLink(profileName, opts = {}) {
|
|
|
268
310
|
// collisions, defensive) so the profile keeps its current view.
|
|
269
311
|
// Returns [{ resource, status, ... }]; status ∈
|
|
270
312
|
// 'unlinked' | 'not-linked' | 'failed'
|
|
313
|
+
//
|
|
314
|
+
// opts.dryRun reports the same statuses without touching the filesystem; dir
|
|
315
|
+
// results then carry `items`, the number of top-level entries that would be
|
|
316
|
+
// copied into the profile.
|
|
271
317
|
function shareUnlink(profileName, opts = {}) {
|
|
318
|
+
const dryRun = Boolean(opts.dryRun);
|
|
272
319
|
profiles.validateProfileName(profileName);
|
|
273
320
|
const pdir = profiles.profileDir(profileName);
|
|
274
321
|
if (!fs.existsSync(pdir)) {
|
|
@@ -290,6 +337,15 @@ function shareUnlink(profileName, opts = {}) {
|
|
|
290
337
|
continue;
|
|
291
338
|
}
|
|
292
339
|
|
|
340
|
+
if (dryRun) {
|
|
341
|
+
const r = { resource, status: 'unlinked' };
|
|
342
|
+
if (!isFileResource(resource)) {
|
|
343
|
+
try { r.items = fs.readdirSync(target).length; } catch { r.items = 0; }
|
|
344
|
+
}
|
|
345
|
+
results.push(r);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
293
349
|
// Read pool content BEFORE removing the link (in case the link is the
|
|
294
350
|
// only path to it... well, not really — target is reachable directly —
|
|
295
351
|
// but reading first means we can fail without leaving a deleted link).
|
|
@@ -783,6 +839,7 @@ module.exports = {
|
|
|
783
839
|
isFileResource,
|
|
784
840
|
isLink,
|
|
785
841
|
isSharedResource,
|
|
842
|
+
resourceState,
|
|
786
843
|
removeLink,
|
|
787
844
|
shareLink,
|
|
788
845
|
shareUnlink,
|
package/lib/spawn.js
CHANGED
|
@@ -155,7 +155,7 @@ function platformSpawn(bin, args, opts) {
|
|
|
155
155
|
return spawn(bin, args, opts);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
function spawnClaude({ args = [], profileDir = null, extraEnv = {} } = {}) {
|
|
158
|
+
function spawnClaude({ args = [], profileDir = null, extraEnv = {}, unsetEnv = [] } = {}) {
|
|
159
159
|
const bin = findClaude();
|
|
160
160
|
if (!bin) {
|
|
161
161
|
const err = new Error(
|
|
@@ -169,6 +169,7 @@ function spawnClaude({ args = [], profileDir = null, extraEnv = {} } = {}) {
|
|
|
169
169
|
warnIfOld(bin);
|
|
170
170
|
|
|
171
171
|
const childEnv = { ...process.env, ...extraEnv };
|
|
172
|
+
for (const name of unsetEnv) delete childEnv[name];
|
|
172
173
|
if (profileDir) {
|
|
173
174
|
childEnv.CLAUDE_CONFIG_DIR = profileDir;
|
|
174
175
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cchost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Multi-account profile manager for Claude Code — switch accounts, share skills/agents/projects, route to custom endpoints. Works on Windows, Linux, and macOS.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"url": "git+https://github.com/allenhack638/cchost.git"
|
|
27
27
|
},
|
|
28
28
|
"bin": {
|
|
29
|
+
"cchost": "bin/cc.js",
|
|
29
30
|
"cc": "bin/cc.js"
|
|
30
31
|
},
|
|
31
32
|
"engines": {
|
|
@@ -47,4 +48,4 @@
|
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"vitest": "^2.1.0"
|
|
49
50
|
}
|
|
50
|
-
}
|
|
51
|
+
}
|