pal-explorer-cli 0.4.1 → 0.4.4
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 +3 -13
- package/bin/pal.js +1 -0
- package/extensions/@palexplorer/analytics/extension.json +3 -3
- package/extensions/@palexplorer/analytics/index.js +1 -1
- package/extensions/@palexplorer/audit/extension.json +1 -1
- package/extensions/@palexplorer/chat/extension.json +2 -2
- package/extensions/@palexplorer/discovery/extension.json +1 -1
- package/extensions/@palexplorer/explorer-integration/extension.json +1 -1
- package/extensions/@palexplorer/groups/extension.json +2 -2
- package/extensions/@palexplorer/networks/extension.json +1 -1
- package/extensions/@palexplorer/share-links/extension.json +2 -2
- package/extensions/@palexplorer/sync/extension.json +2 -2
- package/extensions/@palexplorer/user-mgmt/extension.json +1 -1
- package/extensions/@palexplorer/vfs/extension.json +1 -1
- package/lib/commands/api-keys.js +1 -1
- package/lib/commands/share-link.js +1 -1
- package/lib/commands/web-login.js +79 -0
- package/lib/core/billing.js +6 -21
- package/lib/core/extensions.js +6 -18
- package/lib/core/pro.js +4 -10
- package/lib/core/webServer.js +9 -8
- package/package.json +1 -5
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Pal Explorer (`pe`)
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/pal-explorer-cli)
|
|
4
|
-
[](LICENSE)
|
|
5
5
|
[](https://nodejs.org/)
|
|
6
6
|
|
|
7
7
|
> Peer-to-peer file sharing with end-to-end encryption. No cloud. No middleman.
|
|
@@ -96,8 +96,6 @@ Pal Explorer lets you share files directly with friends using P2P protocols. Fil
|
|
|
96
96
|
# Install CLI globally
|
|
97
97
|
npm install -g pal-explorer-cli
|
|
98
98
|
|
|
99
|
-
# Or clone and link for development
|
|
100
|
-
# git clone https://github.com/hn2/palexplorer && cd palexplorer && npm install && npm link
|
|
101
99
|
|
|
102
100
|
# Create your identity
|
|
103
101
|
pe init "YourName"
|
|
@@ -178,7 +176,7 @@ REST API + WebSocket for real-time transfer monitoring, chat, and Pro/billing ma
|
|
|
178
176
|
| `storage_path` | `./downloads` | Default download directory |
|
|
179
177
|
| `max_connections` | `50` | Max P2P connections |
|
|
180
178
|
| `bandwidth_cap` | `0` | Upload cap in KB/s (0 = unlimited) |
|
|
181
|
-
| `discovery_servers` | `
|
|
179
|
+
| `discovery_servers` | `https://discovery.palexplorer.com` | Discovery server URLs (comma-separated) |
|
|
182
180
|
| `announce_interval` | -- | DHT announce interval in seconds |
|
|
183
181
|
|
|
184
182
|
Config file location: `~/.config/palexplorer-cli/config.json`
|
|
@@ -300,15 +298,7 @@ docs/ Documentation
|
|
|
300
298
|
|
|
301
299
|
For full details, see [SECURITY.md](docs/SECURITY.md).
|
|
302
300
|
|
|
303
|
-
## Contributing
|
|
304
|
-
|
|
305
|
-
1. Fork the repository
|
|
306
|
-
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
307
|
-
3. Write tests for new functionality
|
|
308
|
-
4. Ensure `npm test` passes
|
|
309
|
-
5. Submit a pull request
|
|
310
|
-
|
|
311
301
|
## License
|
|
312
302
|
|
|
313
|
-
|
|
303
|
+
Proprietary. All rights reserved. See [LICENSE.md](LICENSE.md).
|
|
314
304
|
|
package/bin/pal.js
CHANGED
|
@@ -81,6 +81,7 @@ const commands = [
|
|
|
81
81
|
['log', '../lib/commands/log.js'],
|
|
82
82
|
['completion', '../lib/commands/completion.js'],
|
|
83
83
|
['web', '../lib/commands/web.js'],
|
|
84
|
+
['web-login', '../lib/commands/web-login.js'],
|
|
84
85
|
['gui-share', '../lib/commands/gui-share.js'],
|
|
85
86
|
['vfs', '../lib/commands/vfs.js'],
|
|
86
87
|
['file', '../lib/commands/file.js'],
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
],
|
|
18
18
|
"permissions": ["config:read", "config:write", "net:http"],
|
|
19
19
|
"config": {
|
|
20
|
-
"enabled": { "type": "boolean", "default":
|
|
21
|
-
"posthogKey": { "type": "string", "default": "", "description": "PostHog project API key (leave empty for default)" },
|
|
20
|
+
"enabled": { "type": "boolean", "default": true, "description": "Enable anonymous usage analytics" },
|
|
21
|
+
"posthogKey": { "type": "string", "default": "phc_PSslPgpRuFzQf6s5qbN9atXFGUDHzCvMlmpBTtdTkte", "description": "PostHog project API key (leave empty for default)" },
|
|
22
22
|
"posthogHost": { "type": "string", "default": "https://us.i.posthog.com", "description": "PostHog ingestion host" },
|
|
23
23
|
"sessionTracking": { "type": "boolean", "default": true, "description": "Track session duration" }
|
|
24
24
|
},
|
|
25
|
-
"
|
|
25
|
+
"tier": "free",
|
|
26
26
|
"minAppVersion": "0.4.0"
|
|
27
27
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
import { createRequire } from 'module';
|
|
3
3
|
|
|
4
|
-
const DEFAULT_POSTHOG_KEY = process.env.PAL_POSTHOG_KEY || '';
|
|
4
|
+
const DEFAULT_POSTHOG_KEY = process.env.PAL_POSTHOG_KEY || 'phc_PSslPgpRuFzQf6s5qbN9atXFGUDHzCvMlmpBTtdTkte';
|
|
5
5
|
const FLUSH_INTERVAL = 30_000;
|
|
6
6
|
const MAX_QUEUE = 100;
|
|
7
7
|
|
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
{ "id": "chat", "label": "chat", "icon": "MessagesSquare", "section": "social" }
|
|
13
13
|
]
|
|
14
14
|
},
|
|
15
|
-
"config": { "enabled": { "type": "boolean", "default":
|
|
16
|
-
"
|
|
15
|
+
"config": { "enabled": { "type": "boolean", "default": false } },
|
|
16
|
+
"tier": "free"
|
|
17
17
|
}
|
|
@@ -11,6 +11,6 @@
|
|
|
11
11
|
"refreshInterval": { "type": "number", "default": 1800000, "description": "Server list refresh interval in ms (default: 30 min)" },
|
|
12
12
|
"enableGossip": { "type": "boolean", "default": true, "description": "Exchange server lists with connected peers" }
|
|
13
13
|
},
|
|
14
|
-
"
|
|
14
|
+
"tier": "free",
|
|
15
15
|
"minAppVersion": "0.4.0"
|
|
16
16
|
}
|
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
{ "id": "groups", "label": "groups", "icon": "UsersRound", "section": "social" }
|
|
13
13
|
]
|
|
14
14
|
},
|
|
15
|
-
"config": { "enabled": { "type": "boolean", "default":
|
|
16
|
-
"
|
|
15
|
+
"config": { "enabled": { "type": "boolean", "default": false } },
|
|
16
|
+
"tier": "free"
|
|
17
17
|
}
|
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
{ "id": "links", "label": "links", "icon": "Key", "section": "content" }
|
|
13
13
|
]
|
|
14
14
|
},
|
|
15
|
-
"config": { "enabled": { "type": "boolean", "default":
|
|
16
|
-
"
|
|
15
|
+
"config": { "enabled": { "type": "boolean", "default": false } },
|
|
16
|
+
"tier": "free"
|
|
17
17
|
}
|
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
{ "id": "sync", "label": "sync", "icon": "FolderSync", "section": "content" }
|
|
13
13
|
]
|
|
14
14
|
},
|
|
15
|
-
"config": { "enabled": { "type": "boolean", "default":
|
|
16
|
-
"
|
|
15
|
+
"config": { "enabled": { "type": "boolean", "default": false } },
|
|
16
|
+
"tier": "free"
|
|
17
17
|
}
|
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
"driveLetter": { "type": "string", "default": "P", "description": "Windows drive letter" },
|
|
13
13
|
"autoMount": { "type": "boolean", "default": true, "description": "Auto-mount on startup" }
|
|
14
14
|
},
|
|
15
|
-
"
|
|
15
|
+
"tier": "free",
|
|
16
16
|
"minAppVersion": "0.5.0"
|
|
17
17
|
}
|
package/lib/commands/api-keys.js
CHANGED
|
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import config from '../utils/config.js';
|
|
3
3
|
import { getIdentity } from '../core/identity.js';
|
|
4
4
|
|
|
5
|
-
const DISCOVERY_URL = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || '
|
|
5
|
+
const DISCOVERY_URL = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'https://discovery.palexplorer.com';
|
|
6
6
|
|
|
7
7
|
export default function apiKeysCommand(program) {
|
|
8
8
|
const cmd = program
|
|
@@ -3,7 +3,7 @@ import config from '../utils/config.js';
|
|
|
3
3
|
import { getIdentity } from '../core/identity.js';
|
|
4
4
|
import { listShares, getShareKey } from '../core/shares.js';
|
|
5
5
|
|
|
6
|
-
const DISCOVERY_URL = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || '
|
|
6
|
+
const DISCOVERY_URL = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'https://discovery.palexplorer.com';
|
|
7
7
|
|
|
8
8
|
export default function shareLinkCommand(program) {
|
|
9
9
|
const cmd = program
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getIdentity } from '../core/identity.js';
|
|
3
|
+
import config from '../utils/config.js';
|
|
4
|
+
|
|
5
|
+
export default function webLoginCommand(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('web-login <code>')
|
|
8
|
+
.description('sign in to palexplorer.com using your desktop identity')
|
|
9
|
+
.option('--host <host>', 'Web server host', 'palexplorer.com')
|
|
10
|
+
.addHelpText('after', `
|
|
11
|
+
Examples:
|
|
12
|
+
$ pe web-login A7X-3K9 Sign in to palexplorer.com
|
|
13
|
+
$ pe web-login A7X-3K9 --host localhost:4000 Use local dev server
|
|
14
|
+
`)
|
|
15
|
+
.action(async (code, opts) => {
|
|
16
|
+
try {
|
|
17
|
+
const identity = getIdentity();
|
|
18
|
+
if (!identity) {
|
|
19
|
+
console.log(chalk.red('No identity found. Run: pe init'));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const host = opts.host;
|
|
24
|
+
const protocol = host.startsWith('localhost') || host.startsWith('127.0.0.1') ? 'http' : 'https';
|
|
25
|
+
const baseUrl = `${protocol}://${host}`;
|
|
26
|
+
|
|
27
|
+
console.log(chalk.gray(`Resolving code ${code}...`));
|
|
28
|
+
|
|
29
|
+
const resolveRes = await fetch(`${baseUrl}/api/auth/qr/resolve`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ code }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!resolveRes.ok) {
|
|
36
|
+
const data = await resolveRes.json().catch(() => ({}));
|
|
37
|
+
console.log(chalk.red(`Failed: ${data.error || 'Code not found or expired'}`));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { sessionId, challenge } = await resolveRes.json();
|
|
42
|
+
|
|
43
|
+
console.log(chalk.gray('Signing challenge...'));
|
|
44
|
+
|
|
45
|
+
let sodium;
|
|
46
|
+
try {
|
|
47
|
+
sodium = (await import('sodium-native')).default;
|
|
48
|
+
} catch {
|
|
49
|
+
sodium = await import('sodium-native');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
|
|
53
|
+
const challengeBuf = Buffer.from(challenge, 'hex');
|
|
54
|
+
const privateKey = Buffer.from(identity.privateKey, 'hex');
|
|
55
|
+
sodium.crypto_sign_detached(sig, challengeBuf, privateKey);
|
|
56
|
+
|
|
57
|
+
const verifyRes = await fetch(`${baseUrl}/api/auth/qr/verify`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
sessionId,
|
|
62
|
+
publicKey: identity.publicKey,
|
|
63
|
+
signature: sig.toString('hex'),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!verifyRes.ok) {
|
|
68
|
+
const data = await verifyRes.json().catch(() => ({}));
|
|
69
|
+
console.log(chalk.red(`Verification failed: ${data.error || 'Unknown error'}`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log(chalk.green('Signed in to web successfully!'));
|
|
74
|
+
console.log(chalk.gray('Your browser should update automatically.'));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.log(chalk.red(`Error: ${err.message}`));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
package/lib/core/billing.js
CHANGED
|
@@ -273,28 +273,13 @@ function downgradeToFree(reason) {
|
|
|
273
273
|
let _revalidationPromise = null;
|
|
274
274
|
|
|
275
275
|
export function getActivePlan() {
|
|
276
|
-
|
|
277
|
-
if (!billing) {
|
|
278
|
-
return { key: 'free', ...PLANS.free, expiresAt: null, source: 'none' };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (billing.expiresAt && new Date(billing.expiresAt) < new Date()) {
|
|
282
|
-
return { key: 'free', ...PLANS.free, expiresAt: null, source: 'expired' };
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Trigger non-blocking revalidation if needed
|
|
286
|
-
if (shouldRevalidate() && !_revalidationPromise) {
|
|
287
|
-
_revalidationPromise = revalidateLicense().finally(() => { _revalidationPromise = null; });
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const planKey = billing.plan || 'pro_monthly';
|
|
291
|
-
const plan = PLANS[planKey] || PLANS.pro_monthly;
|
|
276
|
+
// BETA: all features unlocked — re-enable billing when ready
|
|
292
277
|
return {
|
|
293
|
-
key:
|
|
294
|
-
...
|
|
295
|
-
expiresAt:
|
|
296
|
-
source: '
|
|
297
|
-
customerEmail:
|
|
278
|
+
key: 'enterprise',
|
|
279
|
+
...PLANS.enterprise,
|
|
280
|
+
expiresAt: null,
|
|
281
|
+
source: 'beta',
|
|
282
|
+
customerEmail: null,
|
|
298
283
|
};
|
|
299
284
|
}
|
|
300
285
|
|
package/lib/core/extensions.js
CHANGED
|
@@ -54,25 +54,17 @@ const REQUIRE_SIGNATURE = config.get('extensionRequireSignature') ?? (process.en
|
|
|
54
54
|
const TIER_RANK = { free: 0, pro: 1, enterprise: 2 };
|
|
55
55
|
|
|
56
56
|
function checkExtensionTier(manifest) {
|
|
57
|
+
// BETA: all extensions free — bypass tier enforcement
|
|
57
58
|
const tier = manifest.tier || (manifest.pro ? 'pro' : 'free');
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
let plan;
|
|
61
|
-
try { plan = getActivePlan(); } catch { plan = { key: 'free' }; }
|
|
62
|
-
|
|
63
|
-
const planTier = plan.key === 'free' ? 'free'
|
|
64
|
-
: plan.key.startsWith('enterprise') ? 'enterprise' : 'pro';
|
|
65
|
-
|
|
66
|
-
if (TIER_RANK[planTier] >= TIER_RANK[tier]) {
|
|
67
|
-
return { allowed: true, tier, planTier };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return { allowed: false, tier, planTier };
|
|
59
|
+
return { allowed: true, tier, planTier: 'free' };
|
|
71
60
|
}
|
|
72
61
|
|
|
73
62
|
const OFFLINE_GRACE_DAYS = 7;
|
|
74
63
|
|
|
75
64
|
async function verifyExtensionLicense(manifest, extPath) {
|
|
65
|
+
// BETA: all extensions free — skip license verification
|
|
66
|
+
return true;
|
|
67
|
+
|
|
76
68
|
const tier = manifest.tier || (manifest.pro ? 'pro' : 'free');
|
|
77
69
|
if (tier === 'free' || manifest.bundled) return true;
|
|
78
70
|
|
|
@@ -821,12 +813,8 @@ async function installExtension(source, { skipPrompt = false } = {}) {
|
|
|
821
813
|
throw new Error('Cannot install extensions using reserved @palexplorer namespace.');
|
|
822
814
|
}
|
|
823
815
|
|
|
824
|
-
//
|
|
816
|
+
// BETA: all extensions free — tier check always passes
|
|
825
817
|
const tierCheck = checkExtensionTier(manifest);
|
|
826
|
-
if (!tierCheck.allowed) {
|
|
827
|
-
if (sourcePath !== source) fs.rmSync(sourcePath, { recursive: true, force: true });
|
|
828
|
-
throw new Error(`Extension '${manifest.name}' requires a ${tierCheck.tier} subscription (current: ${tierCheck.planTier}). Upgrade at https://palexplorer.com/pro`);
|
|
829
|
-
}
|
|
830
818
|
|
|
831
819
|
// AST analysis (replaces old regex scanning)
|
|
832
820
|
const analysis = analyzeExtension(sourcePath, manifest);
|
package/lib/core/pro.js
CHANGED
|
@@ -5,23 +5,17 @@ export const FREE_LIMITS = {
|
|
|
5
5
|
maxShareRecipients: PLANS.free.limits.maxRecipients,
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
// BETA: all features unlocked — re-enable billing when ready
|
|
8
9
|
export function isPro() {
|
|
9
|
-
|
|
10
|
-
return plan.key !== 'free';
|
|
10
|
+
return true;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function isEnterprise() {
|
|
14
|
-
|
|
15
|
-
return plan.key === 'enterprise';
|
|
14
|
+
return true;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
export function checkLimit(feature, currentCount) {
|
|
19
|
-
|
|
20
|
-
const limit = FREE_LIMITS[feature];
|
|
21
|
-
if (limit == null) return;
|
|
22
|
-
if (currentCount > limit) {
|
|
23
|
-
throw new Error(`Free tier limit reached: ${feature} (max ${limit}). Upgrade to Pro for unlimited access.`);
|
|
24
|
-
}
|
|
18
|
+
return;
|
|
25
19
|
}
|
|
26
20
|
|
|
27
21
|
export { getFeature, checkFeature, getPlanLimits };
|
package/lib/core/webServer.js
CHANGED
|
@@ -318,7 +318,7 @@ export function startWebServer(port, torrentClient, { bindAddress = '127.0.0.1'
|
|
|
318
318
|
const identity = config.get('identity');
|
|
319
319
|
if (!identity?.handle) return res.status(400).json({ error: 'No handle registered' });
|
|
320
320
|
|
|
321
|
-
const discoveryUrl = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || '
|
|
321
|
+
const discoveryUrl = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'https://discovery.palexplorer.com';
|
|
322
322
|
const timestamp = Date.now();
|
|
323
323
|
|
|
324
324
|
try {
|
|
@@ -558,14 +558,15 @@ export function startWebServer(port, torrentClient, { bindAddress = '127.0.0.1'
|
|
|
558
558
|
|
|
559
559
|
// ── Billing / Pro Status ──
|
|
560
560
|
|
|
561
|
+
// BETA: all features unlocked — re-enable billing when ready
|
|
561
562
|
app.get('/api/billing/status', (req, res) => {
|
|
562
|
-
const pro = isPro();
|
|
563
|
-
const license = config.get('license') || {};
|
|
564
563
|
res.json({
|
|
565
|
-
plan:
|
|
566
|
-
active:
|
|
567
|
-
|
|
568
|
-
|
|
564
|
+
plan: 'enterprise',
|
|
565
|
+
active: true,
|
|
566
|
+
isPro: true,
|
|
567
|
+
expiresAt: null,
|
|
568
|
+
licenseKey: null,
|
|
569
|
+
source: 'beta',
|
|
569
570
|
});
|
|
570
571
|
});
|
|
571
572
|
|
|
@@ -598,7 +599,7 @@ export function startWebServer(port, torrentClient, { bindAddress = '127.0.0.1'
|
|
|
598
599
|
const identity = config.get('identity');
|
|
599
600
|
if (!identity?.handle) return res.status(400).json({ error: 'No handle registered' });
|
|
600
601
|
|
|
601
|
-
const discoveryUrl = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || '
|
|
602
|
+
const discoveryUrl = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'https://discovery.palexplorer.com';
|
|
602
603
|
const timestamp = Date.now();
|
|
603
604
|
|
|
604
605
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pal-explorer-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "P2P encrypted file sharing CLI — share files directly with friends, not with the cloud",
|
|
5
5
|
"main": "bin/pal.js",
|
|
6
6
|
"bin": {
|
|
@@ -60,10 +60,6 @@
|
|
|
60
60
|
"author": "Palexplorer",
|
|
61
61
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
62
62
|
"homepage": "https://palexplorer.com",
|
|
63
|
-
"repository": {
|
|
64
|
-
"type": "git",
|
|
65
|
-
"url": "https://github.com/hn2/palexplorer.git"
|
|
66
|
-
},
|
|
67
63
|
"type": "module",
|
|
68
64
|
"devDependencies": {
|
|
69
65
|
"@playwright/test": "^1.58.2"
|