@shiplens/cli 1.4.2 → 1.4.3
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 +30 -30
- package/docs/02_CLI_COMMAND_REFERENCE.md +195 -0
- package/lib/api.js +35 -55
- package/lib/assets/skill.js +137 -139
- package/lib/cli.js +59 -55
- package/lib/commands/auth.js +36 -41
- package/lib/commands/context.js +13 -18
- package/lib/commands/dashboards.js +16 -24
- package/lib/commands/doctor.js +43 -34
- package/lib/commands/heatmap.js +14 -9
- package/lib/commands/init.js +51 -51
- package/lib/commands/mcp.js +2 -2
- package/lib/commands/pages.js +19 -23
- package/lib/commands/projects.js +13 -13
- package/lib/commands/query.js +10 -12
- package/lib/commands/sql.js +4 -5
- package/lib/commands/summary.js +7 -7
- package/lib/injector.js +19 -22
- package/lib/mcp-config.js +5 -5
- package/lib/taxonomy.js +22 -25
- package/package.json +2 -2
- package/prompts/prompts_cli_en.md +743 -0
- package/docs/02_SHIPLENS_CLI_/345/205/250/345/221/275/344/273/244/345/217/202/350/200/203/346/211/213/345/206/214.md +0 -205
package/lib/injector.js
CHANGED
|
@@ -95,7 +95,7 @@ function detectProject(dir = process.cwd()) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
//
|
|
98
|
+
// Extract description fallback from README if missing
|
|
99
99
|
if (!result.description) {
|
|
100
100
|
const readmeCandidates = ['README.md', 'readme.md', 'README.MD'];
|
|
101
101
|
for (const r of readmeCandidates) {
|
|
@@ -113,7 +113,7 @@ function detectProject(dir = process.cwd()) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
// 4
|
|
116
|
+
// 4-level taxonomy inference
|
|
117
117
|
const taxonomy = inferTaxonomy({
|
|
118
118
|
name: result.project_name,
|
|
119
119
|
description: result.description,
|
|
@@ -286,7 +286,7 @@ function injectCRA(dir, appId) {
|
|
|
286
286
|
if (content.includes('@shiplens/sdk') || content.includes('initShiplens') || content.includes(appId)) {
|
|
287
287
|
return cand;
|
|
288
288
|
}
|
|
289
|
-
const injection = `\n// Shiplens SDK -
|
|
289
|
+
const injection = `\n// Shiplens SDK - User Analytics\nconst { initShiplens } = require('@shiplens/sdk');\ninitShiplens({ appId: '${appId}' });\n`;
|
|
290
290
|
fs.writeFileSync(p, content + injection, 'utf8');
|
|
291
291
|
return cand;
|
|
292
292
|
}
|
|
@@ -351,7 +351,7 @@ async function installSDKDependency(dir, pkgManager = 'npm') {
|
|
|
351
351
|
const sources = [
|
|
352
352
|
{
|
|
353
353
|
id: 'npmjs',
|
|
354
|
-
name: '
|
|
354
|
+
name: 'npm registry',
|
|
355
355
|
getCommand: (pm) => {
|
|
356
356
|
if (pm === 'pnpm') return `pnpm${ext} add @shiplens/sdk`;
|
|
357
357
|
if (pm === 'yarn') return `yarn${ext} add @shiplens/sdk`;
|
|
@@ -361,7 +361,7 @@ async function installSDKDependency(dir, pkgManager = 'npm') {
|
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
id: 'npmmirror',
|
|
364
|
-
name: '
|
|
364
|
+
name: 'Alibaba Cloud mirror (npmmirror)',
|
|
365
365
|
getCommand: (pm) => {
|
|
366
366
|
const mirror = '--registry=https://registry.npmmirror.com';
|
|
367
367
|
if (pm === 'pnpm') return `pnpm${ext} add @shiplens/sdk ${mirror}`;
|
|
@@ -372,7 +372,7 @@ async function installSDKDependency(dir, pkgManager = 'npm') {
|
|
|
372
372
|
},
|
|
373
373
|
{
|
|
374
374
|
id: 'github',
|
|
375
|
-
name: 'GitHub
|
|
375
|
+
name: 'GitHub mirror',
|
|
376
376
|
getCommand: (pm) => {
|
|
377
377
|
const repoUrl = 'https://github.com/Hyperlong/shiplens-sdk.git';
|
|
378
378
|
if (pm === 'pnpm') return `pnpm${ext} add ${repoUrl}`;
|
|
@@ -383,7 +383,7 @@ async function installSDKDependency(dir, pkgManager = 'npm') {
|
|
|
383
383
|
},
|
|
384
384
|
{
|
|
385
385
|
id: 'cdn',
|
|
386
|
-
name: 'Shiplens
|
|
386
|
+
name: 'Shiplens CDN mirror',
|
|
387
387
|
getCommand: (pm) => {
|
|
388
388
|
const cdnUrl = 'https://cdn.shiplens.dev/packages/shiplens-sdk.tgz';
|
|
389
389
|
if (pm === 'pnpm') return `pnpm${ext} add ${cdnUrl}`;
|
|
@@ -394,8 +394,8 @@ async function installSDKDependency(dir, pkgManager = 'npm') {
|
|
|
394
394
|
},
|
|
395
395
|
];
|
|
396
396
|
|
|
397
|
-
const TIER1_DELAY_MS = 20 * 1000; //
|
|
398
|
-
const TOTAL_TIMEOUT_MS = 5 * 60 * 1000; //
|
|
397
|
+
const TIER1_DELAY_MS = 20 * 1000; // 20s
|
|
398
|
+
const TOTAL_TIMEOUT_MS = 5 * 60 * 1000; // 5min
|
|
399
399
|
|
|
400
400
|
return new Promise((resolve) => {
|
|
401
401
|
let isDone = false;
|
|
@@ -473,25 +473,25 @@ async function installSDKDependency(dir, pkgManager = 'npm') {
|
|
|
473
473
|
cleanupAndFinish({
|
|
474
474
|
success: false,
|
|
475
475
|
manager: pkgManager,
|
|
476
|
-
error: '4
|
|
476
|
+
error: 'All 4 download sources failed or timed out',
|
|
477
477
|
});
|
|
478
478
|
}
|
|
479
479
|
};
|
|
480
480
|
|
|
481
|
-
// t = 0s:
|
|
481
|
+
// t = 0s: Start Tier 1 (npmjs)
|
|
482
482
|
runSource(sources[0]);
|
|
483
483
|
|
|
484
|
-
// t = 20s:
|
|
484
|
+
// t = 20s: If still running, keep npmjs alive and start npmmirror + GitHub + CDN
|
|
485
485
|
tier2Timer = setTimeout(() => {
|
|
486
486
|
startBackupSources();
|
|
487
487
|
}, TIER1_DELAY_MS);
|
|
488
488
|
|
|
489
|
-
// t = 5min:
|
|
489
|
+
// t = 5min: Total timeout across all sources
|
|
490
490
|
totalTimer = setTimeout(() => {
|
|
491
491
|
cleanupAndFinish({
|
|
492
492
|
success: false,
|
|
493
493
|
manager: pkgManager,
|
|
494
|
-
error: '
|
|
494
|
+
error: 'SDK installation timed out after 5 minutes across all download sources',
|
|
495
495
|
});
|
|
496
496
|
}, TOTAL_TIMEOUT_MS);
|
|
497
497
|
});
|
|
@@ -508,14 +508,14 @@ function getGitEmail() {
|
|
|
508
508
|
function injectSkill(dir = process.cwd()) {
|
|
509
509
|
const { SKILL_CONTENT } = require('./assets/skill');
|
|
510
510
|
|
|
511
|
-
// 1.
|
|
511
|
+
// 1. Generate generic Agent Skill (.agents/skills/shiplens/SKILL.md)
|
|
512
512
|
const agentSkillDir = path.join(dir, '.agents', 'skills', 'shiplens');
|
|
513
513
|
try {
|
|
514
514
|
fs.mkdirSync(agentSkillDir, { recursive: true });
|
|
515
515
|
fs.writeFileSync(path.join(agentSkillDir, 'SKILL.md'), SKILL_CONTENT, 'utf8');
|
|
516
516
|
} catch (e) {}
|
|
517
517
|
|
|
518
|
-
// 2.
|
|
518
|
+
// 2. Generate Cursor rule (.cursor/rules/shiplens.mdc)
|
|
519
519
|
const cursorRulesDir = path.join(dir, '.cursor', 'rules');
|
|
520
520
|
try {
|
|
521
521
|
fs.mkdirSync(cursorRulesDir, { recursive: true });
|
|
@@ -526,7 +526,7 @@ function injectSkill(dir = process.cwd()) {
|
|
|
526
526
|
}
|
|
527
527
|
|
|
528
528
|
function detectExistingApp(dir = process.cwd()) {
|
|
529
|
-
// 1.
|
|
529
|
+
// 1. Check local .shiplens.json state machine
|
|
530
530
|
const cfgPath = path.join(dir, '.shiplens.json');
|
|
531
531
|
if (fs.existsSync(cfgPath)) {
|
|
532
532
|
try {
|
|
@@ -543,7 +543,7 @@ function detectExistingApp(dir = process.cwd()) {
|
|
|
543
543
|
} catch (e) {}
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
-
// 2.
|
|
546
|
+
// 2. Check code files for existing instrumentation
|
|
547
547
|
const checkFiles = [
|
|
548
548
|
'src/app/layout.tsx', 'app/layout.tsx',
|
|
549
549
|
'src/app/layout.jsx', 'app/layout.jsx',
|
|
@@ -567,7 +567,6 @@ function detectExistingApp(dir = process.cwd()) {
|
|
|
567
567
|
if (fs.existsSync(p)) {
|
|
568
568
|
try {
|
|
569
569
|
const content = fs.readFileSync(p, 'utf8');
|
|
570
|
-
// 匹配 appId: 'sl_live_...' 或 data-app-id="sl_live_..."
|
|
571
570
|
const matchAppId = content.match(/appId\s*:\s*['"]([^'"]+)['"]/);
|
|
572
571
|
if (matchAppId && matchAppId[1]) {
|
|
573
572
|
return {
|
|
@@ -584,7 +583,6 @@ function detectExistingApp(dir = process.cwd()) {
|
|
|
584
583
|
source_file: rel,
|
|
585
584
|
};
|
|
586
585
|
}
|
|
587
|
-
// 如果包含 @shiplens/sdk 或 cdn.shiplens.dev 但未提取到具体 ID
|
|
588
586
|
if (content.includes('@shiplens/sdk') || content.includes('cdn.shiplens.dev/sdk.js') || content.includes('initShiplens')) {
|
|
589
587
|
return {
|
|
590
588
|
has_existing: true,
|
|
@@ -596,7 +594,7 @@ function detectExistingApp(dir = process.cwd()) {
|
|
|
596
594
|
}
|
|
597
595
|
}
|
|
598
596
|
|
|
599
|
-
// 3.
|
|
597
|
+
// 3. Check package.json dependencies
|
|
600
598
|
const pkgPath = path.join(dir, 'package.json');
|
|
601
599
|
if (fs.existsSync(pkgPath)) {
|
|
602
600
|
try {
|
|
@@ -625,4 +623,3 @@ module.exports = {
|
|
|
625
623
|
installSDKDependency,
|
|
626
624
|
getGitEmail,
|
|
627
625
|
};
|
|
628
|
-
|
package/lib/mcp-config.js
CHANGED
|
@@ -38,7 +38,7 @@ function getMcpServer(client, url) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function getMcpConfig(client, url) {
|
|
41
|
-
if (!CLIENTS.includes(client)) throw makeError(
|
|
41
|
+
if (!CLIENTS.includes(client)) throw makeError(`Unsupported MCP client: ${client}`, 'UNSUPPORTED_MCP_CLIENT');
|
|
42
42
|
return { mcpServers: { shiplens: getMcpServer(client, url) } };
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -49,7 +49,7 @@ function readJsonConfig(filePath) {
|
|
|
49
49
|
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error('root must be object');
|
|
50
50
|
return value;
|
|
51
51
|
} catch (error) {
|
|
52
|
-
throw makeError(
|
|
52
|
+
throw makeError(`Failed to read MCP config at ${filePath}: invalid JSON`, 'INVALID_MCP_CONFIG');
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -70,8 +70,8 @@ function mergeJsonMcpConfig(client, url, filePath) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function configureMcpClient(client, url, options = {}) {
|
|
73
|
-
if (!CLIENTS.includes(client)) throw makeError(
|
|
74
|
-
if (!url) throw makeError('MCP
|
|
73
|
+
if (!CLIENTS.includes(client)) throw makeError(`Unsupported MCP client: ${client}`, 'UNSUPPORTED_MCP_CLIENT');
|
|
74
|
+
if (!url) throw makeError('MCP URL is required', 'MCP_URL_REQUIRED');
|
|
75
75
|
|
|
76
76
|
if (client === 'manual') {
|
|
77
77
|
return { written: false, already_configured: false, path: null, config: getMcpConfig('manual', url) };
|
|
@@ -88,7 +88,7 @@ function configureMcpClient(client, url, options = {}) {
|
|
|
88
88
|
try {
|
|
89
89
|
exec(codexCommand, ['mcp', 'add', 'shiplens', '--', process.platform === 'win32' ? 'npx.cmd' : 'npx', '--yes', 'shiplens-cli', 'mcp', 'serve'], { stdio: 'ignore' });
|
|
90
90
|
} catch (error) {
|
|
91
|
-
throw makeError('
|
|
91
|
+
throw makeError('Failed to configure Codex MCP. Ensure Codex Desktop is installed and codex CLI is available in PATH.', 'CODEX_MCP_CONFIG_FAILED');
|
|
92
92
|
}
|
|
93
93
|
return { written: true, already_configured: false, path: configPath, config: getMcpConfig('codex', url) };
|
|
94
94
|
}
|
package/lib/taxonomy.js
CHANGED
|
@@ -11,7 +11,6 @@ function loadTaxonomy() {
|
|
|
11
11
|
if (fs.existsSync(filePath)) {
|
|
12
12
|
taxonomyCache = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
13
13
|
} else {
|
|
14
|
-
// 容错兜底
|
|
15
14
|
taxonomyCache = { genres: [] };
|
|
16
15
|
}
|
|
17
16
|
} catch (e) {
|
|
@@ -74,15 +73,15 @@ function buildIndex() {
|
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
const CATEGORY_DISPLAY_NAMES = {
|
|
77
|
-
core_features: '
|
|
78
|
-
monetization: '
|
|
79
|
-
social_engagement: '
|
|
80
|
-
technical_platform: '
|
|
81
|
-
content_theme: '
|
|
76
|
+
core_features: 'Core Features',
|
|
77
|
+
monetization: 'Monetization',
|
|
78
|
+
social_engagement: 'Social & Growth',
|
|
79
|
+
technical_platform: 'Technical Platform',
|
|
80
|
+
content_theme: 'Content & Compliance',
|
|
82
81
|
};
|
|
83
82
|
|
|
84
83
|
/**
|
|
85
|
-
*
|
|
84
|
+
* Automatically infer 4-level taxonomy and feature tags from project context
|
|
86
85
|
*/
|
|
87
86
|
function inferTaxonomy(projectContext = {}) {
|
|
88
87
|
const { genresMap, subgenresMap, tagsMap } = buildIndex();
|
|
@@ -103,7 +102,7 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
103
102
|
let matchedSubgenreId = 'developer_tools';
|
|
104
103
|
const matchedTags = new Set();
|
|
105
104
|
|
|
106
|
-
// 1.
|
|
105
|
+
// 1. Game detection
|
|
107
106
|
const isGame =
|
|
108
107
|
depKeys.some((d) => ['phaser', 'pixi.js', 'three', 'kaboom', 'melonjs'].includes(d)) ||
|
|
109
108
|
/\b(game|arcade|rpg|puzzle|casual|roguelike|fps|chess|cards)\b/.test(combinedText);
|
|
@@ -112,7 +111,7 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
112
111
|
matchedGenreId = 'game_casual_puzzle';
|
|
113
112
|
matchedSubgenreId = 'merge_mechanic_puzzle';
|
|
114
113
|
} else {
|
|
115
|
-
// App
|
|
114
|
+
// App branch inference
|
|
116
115
|
if (/\b(vpn|proxy|shadowsocks|v2ray|wireguard|clash)\b/.test(combinedText)) {
|
|
117
116
|
matchedGenreId = 'utilities';
|
|
118
117
|
matchedSubgenreId = 'net_vpn_proxy';
|
|
@@ -120,7 +119,6 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
120
119
|
matchedGenreId = 'utilities';
|
|
121
120
|
matchedSubgenreId = 'system_cleaner_optimizer';
|
|
122
121
|
} else if (/\b(calc|turnip|price|profit|currency|converter|calculator|finance|financial|stock|crypto|accounting|tax|invoice|budget)\b/.test(combinedText)) {
|
|
123
|
-
// 计算器 / 金融理财 / 价格收益预测
|
|
124
122
|
matchedGenreId = 'finance_fintech';
|
|
125
123
|
if (/\b(stock|trading|invest)\b/.test(combinedText)) {
|
|
126
124
|
matchedSubgenreId = 'stock_trading_investment';
|
|
@@ -150,7 +148,7 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
150
148
|
}
|
|
151
149
|
}
|
|
152
150
|
|
|
153
|
-
//
|
|
151
|
+
// Ensure valid genre and subgenre
|
|
154
152
|
if (!genresMap.has(matchedGenreId)) {
|
|
155
153
|
matchedGenreId = genresMap.keys().next().value || 'utilities';
|
|
156
154
|
}
|
|
@@ -161,11 +159,11 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
161
159
|
|
|
162
160
|
const currentSubObj = currentGenreObj.subgenres.get(matchedSubgenreId) || subgenresMap.get(matchedSubgenreId);
|
|
163
161
|
|
|
164
|
-
// 2.
|
|
162
|
+
// 2. Pick feature tags from categories
|
|
165
163
|
if (currentSubObj && currentSubObj.tag_categories) {
|
|
166
164
|
const cats = currentSubObj.tag_categories;
|
|
167
165
|
|
|
168
|
-
// A.
|
|
166
|
+
// A. Core Features (select 2~3)
|
|
169
167
|
const coreList = cats.core_features || [];
|
|
170
168
|
if (coreList.length > 0) {
|
|
171
169
|
let added = 0;
|
|
@@ -184,7 +182,7 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
184
182
|
}
|
|
185
183
|
}
|
|
186
184
|
|
|
187
|
-
// B.
|
|
185
|
+
// B. Monetization (select 1)
|
|
188
186
|
const monList = cats.monetization || [];
|
|
189
187
|
if (monList.length > 0) {
|
|
190
188
|
let monTag = null;
|
|
@@ -198,7 +196,7 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
198
196
|
if (monTag) matchedTags.add(monTag.tag_id);
|
|
199
197
|
}
|
|
200
198
|
|
|
201
|
-
// C.
|
|
199
|
+
// C. Technical Platform (select 1)
|
|
202
200
|
const techList = cats.technical_platform || [];
|
|
203
201
|
if (techList.length > 0) {
|
|
204
202
|
let techTag = techList[0];
|
|
@@ -212,14 +210,14 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
212
210
|
if (techTag) matchedTags.add(techTag.tag_id);
|
|
213
211
|
}
|
|
214
212
|
|
|
215
|
-
// D.
|
|
213
|
+
// D. Social Engagement (select 1)
|
|
216
214
|
const socList = cats.social_engagement || [];
|
|
217
215
|
if (socList.length > 0) {
|
|
218
216
|
matchedTags.add(socList[0].tag_id);
|
|
219
217
|
}
|
|
220
218
|
}
|
|
221
219
|
|
|
222
|
-
//
|
|
220
|
+
// Assemble tag details
|
|
223
221
|
const tagDetails = [];
|
|
224
222
|
for (const tagId of matchedTags) {
|
|
225
223
|
const info = tagsMap.get(tagId);
|
|
@@ -231,12 +229,11 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
231
229
|
category_name: CATEGORY_DISPLAY_NAMES[info.category] || info.category,
|
|
232
230
|
});
|
|
233
231
|
} else {
|
|
234
|
-
// 兜底虚拟 tag
|
|
235
232
|
tagDetails.push({
|
|
236
233
|
tag_id: tagId,
|
|
237
234
|
name: tagId.replace(/_/g, ' '),
|
|
238
235
|
category: 'core_features',
|
|
239
|
-
category_name: '
|
|
236
|
+
category_name: 'Core Features',
|
|
240
237
|
});
|
|
241
238
|
}
|
|
242
239
|
}
|
|
@@ -257,7 +254,7 @@ function inferTaxonomy(projectContext = {}) {
|
|
|
257
254
|
}
|
|
258
255
|
|
|
259
256
|
/**
|
|
260
|
-
*
|
|
257
|
+
* Format taxonomy summary into 4-Level structure
|
|
261
258
|
*/
|
|
262
259
|
function formatTaxonomySummary(taxonomy) {
|
|
263
260
|
if (!taxonomy || !taxonomy.genre) return '';
|
|
@@ -273,9 +270,9 @@ function formatTaxonomySummary(taxonomy) {
|
|
|
273
270
|
}
|
|
274
271
|
|
|
275
272
|
const lines = [
|
|
276
|
-
` •
|
|
277
|
-
` •
|
|
278
|
-
` •
|
|
273
|
+
` • Genre (L1): ${genreStr}`,
|
|
274
|
+
` • Sub-genre (L2): ${subgenreStr}`,
|
|
275
|
+
` • Feature Tags:`,
|
|
279
276
|
];
|
|
280
277
|
|
|
281
278
|
for (const [catKey, items] of Object.entries(categorized)) {
|
|
@@ -287,7 +284,7 @@ function formatTaxonomySummary(taxonomy) {
|
|
|
287
284
|
}
|
|
288
285
|
|
|
289
286
|
/**
|
|
290
|
-
*
|
|
287
|
+
* Resolve taxonomy from genreId, subgenreId, and tagIdList
|
|
291
288
|
*/
|
|
292
289
|
function resolveTaxonomyFromIDs(genreId, subgenreId, tagIdList = []) {
|
|
293
290
|
const { genresMap, subgenresMap, tagsMap } = buildIndex();
|
|
@@ -327,7 +324,7 @@ function resolveTaxonomyFromIDs(genreId, subgenreId, tagIdList = []) {
|
|
|
327
324
|
tag_id: tid,
|
|
328
325
|
name: tid.replace(/_/g, ' '),
|
|
329
326
|
category: 'core_features',
|
|
330
|
-
category_name: '
|
|
327
|
+
category_name: 'Core Features',
|
|
331
328
|
});
|
|
332
329
|
}
|
|
333
330
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shiplens/cli",
|
|
3
|
-
"version": "1.4.
|
|
4
|
-
"description": "Shiplens CLI — Web
|
|
3
|
+
"version": "1.4.3",
|
|
4
|
+
"description": "Shiplens CLI — Automated Web User Analytics & AI Agent Analysis Engine",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cli": "./bin/shiplens.js",
|