@shiplens/cli 1.2.7
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 +89 -0
- package/bin/shiplens.js +5 -0
- package/lib/api.js +376 -0
- package/lib/assets/skill.js +127 -0
- package/lib/assets/taxonomy.json +5184 -0
- package/lib/auth.js +107 -0
- package/lib/cli.js +258 -0
- package/lib/commands/auth.js +242 -0
- package/lib/commands/context.js +185 -0
- package/lib/commands/dashboards.js +80 -0
- package/lib/commands/doctor.js +200 -0
- package/lib/commands/heatmap.js +36 -0
- package/lib/commands/init.js +244 -0
- package/lib/commands/mcp.js +65 -0
- package/lib/commands/pages.js +90 -0
- package/lib/commands/projects.js +76 -0
- package/lib/commands/query.js +81 -0
- package/lib/commands/sql.js +61 -0
- package/lib/commands/summary.js +23 -0
- package/lib/config.js +80 -0
- package/lib/device-env.js +16 -0
- package/lib/index.js +21 -0
- package/lib/injector.js +510 -0
- package/lib/mcp-config.js +96 -0
- package/lib/taxonomy.js +354 -0
- package/package.json +44 -0
- package/prompts/README.md +21 -0
- package/prompts/prompts_cli_en.md +671 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const { detectProject, detectExistingApp, injectSDK, injectSkill, installSDKDependency, getGitEmail, autoGitCommit } = require('../injector');
|
|
3
|
+
const { saveLocalConfig } = require('../config');
|
|
4
|
+
const { saveDeviceEnv } = require('../device-env');
|
|
5
|
+
const { resolveTaxonomyFromIDs, formatTaxonomySummary } = require('../taxonomy');
|
|
6
|
+
|
|
7
|
+
async function handleInit(args, flags, ctx) {
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
const wd = process.cwd();
|
|
10
|
+
const detected = detectProject(wd);
|
|
11
|
+
|
|
12
|
+
const projectName = flags.name || detected.project_name || 'My App';
|
|
13
|
+
const description = flags.description || detected.description || '';
|
|
14
|
+
const framework = flags.framework || detected.framework;
|
|
15
|
+
|
|
16
|
+
// Taxonomy resolution
|
|
17
|
+
let taxonomy = detected.taxonomy;
|
|
18
|
+
if (flags.genre || flags.subgenre || flags.tags) {
|
|
19
|
+
const rawTags = flags.tags ? (Array.isArray(flags.tags) ? flags.tags : flags.tags.split(',').map((t) => t.trim())) : (taxonomy ? taxonomy.feature_tag_ids : []);
|
|
20
|
+
taxonomy = resolveTaxonomyFromIDs(flags.genre, flags.subgenre, rawTags);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const genreId = taxonomy?.genre?.id || 'utilities';
|
|
24
|
+
const subgenreId = taxonomy?.subgenre?.id || 'browser_web_utility';
|
|
25
|
+
const featureTagIds = taxonomy?.feature_tag_ids || [];
|
|
26
|
+
if (featureTagIds.length > 10) {
|
|
27
|
+
throw new Error('Maximum 10 feature tags allowed.');
|
|
28
|
+
}
|
|
29
|
+
const industry = flags.industry || subgenreId || 'saas';
|
|
30
|
+
|
|
31
|
+
// 1. Safety check for existing project configuration
|
|
32
|
+
const existing = detectExistingApp(wd);
|
|
33
|
+
if (existing.has_existing && !flags.force) {
|
|
34
|
+
if (ctx.isJSON) {
|
|
35
|
+
const result = {
|
|
36
|
+
ok: false,
|
|
37
|
+
code: 'PROJECT_EXISTS_LOCALLY',
|
|
38
|
+
message: `Existing Shiplens configuration detected (App ID: ${existing.app_id}) in ${existing.source_file}. Use --force to overwrite.`,
|
|
39
|
+
existing_app_id: existing.app_id,
|
|
40
|
+
source_file: existing.source_file,
|
|
41
|
+
project_name: existing.project_name || projectName,
|
|
42
|
+
dashboard_url: `${ctx.client.baseURL}/dashboard/${existing.app_id}`,
|
|
43
|
+
};
|
|
44
|
+
ctx.output(result);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
47
|
+
} else {
|
|
48
|
+
console.log(`\nā ļø Existing Shiplens project detected!`);
|
|
49
|
+
console.log(`š App ID: ${existing.app_id} (Source: ${existing.source_file})`);
|
|
50
|
+
console.log(`š Dashboard: ${ctx.client.baseURL}/dashboard/${existing.app_id}`);
|
|
51
|
+
console.log(`\nš” Option 1 [Recommended]: Keep existing setup and reuse App ID.`);
|
|
52
|
+
console.log(`š” Option 2: Run with --force to overwrite and create a new project:`);
|
|
53
|
+
console.log(` npx shiplens-cli init --force\n`);
|
|
54
|
+
|
|
55
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
56
|
+
const confirmed = await new Promise((resolve) => {
|
|
57
|
+
rl.question('Overwrite and create a new project? (y/N): ', (ans) => {
|
|
58
|
+
rl.close();
|
|
59
|
+
resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
if (!confirmed) {
|
|
63
|
+
console.log('š Operation cancelled. Existing configuration preserved.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Inject AI Skill (.agents/skills/shiplens/SKILL.md & Cursor rules)
|
|
70
|
+
const skillFile = injectSkill(wd);
|
|
71
|
+
|
|
72
|
+
// 3. Register project on cloud
|
|
73
|
+
let appId = '';
|
|
74
|
+
let dashboardUrl = '';
|
|
75
|
+
let accountStatus = 'not_logged_in_unlinked';
|
|
76
|
+
let accountStatusText = 'Not Logged In (Project unlinked)';
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const connResp = await ctx.client.connect({
|
|
80
|
+
project_name: projectName,
|
|
81
|
+
description,
|
|
82
|
+
industry,
|
|
83
|
+
genre_id: genreId,
|
|
84
|
+
subgenre_id: subgenreId,
|
|
85
|
+
feature_tags: featureTagIds.join(','),
|
|
86
|
+
platform: framework,
|
|
87
|
+
});
|
|
88
|
+
appId = connResp.app_id;
|
|
89
|
+
dashboardUrl = connResp.dashboard_url || `${ctx.client.baseURL}/dashboard/${appId}`;
|
|
90
|
+
|
|
91
|
+
if (ctx.resolvedAuth && ctx.resolvedAuth.is_present) {
|
|
92
|
+
if (connResp.account_linked !== false && connResp.linked !== false && !connResp.unlinked) {
|
|
93
|
+
accountStatus = 'logged_in_linked';
|
|
94
|
+
accountStatusText = 'Logged In (Project linked to account)';
|
|
95
|
+
} else {
|
|
96
|
+
accountStatus = 'logged_in_unlinked';
|
|
97
|
+
accountStatusText = 'Logged In (Project not linked to account)';
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
accountStatus = 'not_logged_in_unlinked';
|
|
101
|
+
accountStatusText = 'Not Logged In (Project unlinked)';
|
|
102
|
+
}
|
|
103
|
+
} catch (netErr) {
|
|
104
|
+
netErr.code = netErr.code || 'CONNECT_FAILED';
|
|
105
|
+
throw netErr;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. Inject SDK code
|
|
109
|
+
const injectedFile = injectSDK(wd, framework, appId);
|
|
110
|
+
|
|
111
|
+
// 5. Save local configuration
|
|
112
|
+
saveLocalConfig(wd, {
|
|
113
|
+
app_id: appId,
|
|
114
|
+
project_name: projectName,
|
|
115
|
+
description,
|
|
116
|
+
industry,
|
|
117
|
+
genre_id: genreId,
|
|
118
|
+
subgenre_id: subgenreId,
|
|
119
|
+
feature_tags: featureTagIds,
|
|
120
|
+
api_url: ctx.client.baseURL,
|
|
121
|
+
last_synced_at: new Date().toISOString(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// 6. Install SDK dependency
|
|
125
|
+
let installResult = { success: true, manager: 'skipped' };
|
|
126
|
+
if (!flags['no-install'] && detected.package_manager) {
|
|
127
|
+
installResult = installSDKDependency(wd, detected.package_manager);
|
|
128
|
+
if (!installResult.success) {
|
|
129
|
+
const errMsg = `SDK installation failed: Unable to download @shiplens/sdk from npm registry, GitHub mirror, or Shiplens CDN mirror.\n` +
|
|
130
|
+
`Troubleshooting steps:\n` +
|
|
131
|
+
` 1. Check your local network connection or HTTP proxy / VPN settings;\n` +
|
|
132
|
+
` 2. Try running manually: npm install @shiplens/sdk;\n` +
|
|
133
|
+
` 3. Visit https://shiplens.com for latest SDK status and installation guides.`;
|
|
134
|
+
|
|
135
|
+
if (ctx.isJSON) {
|
|
136
|
+
ctx.output({
|
|
137
|
+
ok: false,
|
|
138
|
+
code: 'SDK_INSTALL_FAILED',
|
|
139
|
+
message: errMsg,
|
|
140
|
+
error_detail: installResult.error,
|
|
141
|
+
app_id: appId,
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
console.error(`\nā ${errMsg}\n`);
|
|
145
|
+
}
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 7. Bind email if specified
|
|
152
|
+
let targetEmail = flags.email;
|
|
153
|
+
if (targetEmail === 'auto') {
|
|
154
|
+
targetEmail = getGitEmail();
|
|
155
|
+
}
|
|
156
|
+
let authStatus = 'pending';
|
|
157
|
+
if (targetEmail) {
|
|
158
|
+
try {
|
|
159
|
+
const emailResponse = await ctx.client.startEmail({
|
|
160
|
+
email: targetEmail,
|
|
161
|
+
project_id: appId,
|
|
162
|
+
project_name: projectName,
|
|
163
|
+
client_name: 'Shiplens CLI',
|
|
164
|
+
device_os: process.platform,
|
|
165
|
+
device_name: require('os').hostname(),
|
|
166
|
+
});
|
|
167
|
+
saveDeviceEnv(emailResponse.shiplens_env, wd);
|
|
168
|
+
authStatus = 'magic_link_sent';
|
|
169
|
+
} catch (e) {
|
|
170
|
+
authStatus = 'failed';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 8. Auto Git commit
|
|
175
|
+
let gitCommitResult = { committed: false };
|
|
176
|
+
if (!flags['no-commit']) {
|
|
177
|
+
gitCommitResult = autoGitCommit(wd, 'feat: Integrate Shiplens SDK and configure dashboard');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const elapsed = Date.now() - start;
|
|
181
|
+
const taxonomyFormatted = formatTaxonomySummary(taxonomy);
|
|
182
|
+
|
|
183
|
+
const result = {
|
|
184
|
+
ok: true,
|
|
185
|
+
app_id: appId,
|
|
186
|
+
project_name: projectName,
|
|
187
|
+
description: description || undefined,
|
|
188
|
+
taxonomy,
|
|
189
|
+
dashboard_url: dashboardUrl,
|
|
190
|
+
framework_detected: framework,
|
|
191
|
+
injected_file: injectedFile,
|
|
192
|
+
user_account_status: accountStatus,
|
|
193
|
+
user_account_text: accountStatusText,
|
|
194
|
+
sdk_installed: installResult.success,
|
|
195
|
+
sdk_manager: installResult.manager,
|
|
196
|
+
skill_injected: true,
|
|
197
|
+
skill_file: skillFile,
|
|
198
|
+
package_manager: detected.package_manager,
|
|
199
|
+
auth_status: authStatus,
|
|
200
|
+
shiplens_env_written: authStatus === 'magic_link_sent',
|
|
201
|
+
activation_required: authStatus === 'pending',
|
|
202
|
+
free_tier_info: '50,000 free events/month (~5,000 unique visitors)',
|
|
203
|
+
bound_email: targetEmail || undefined,
|
|
204
|
+
git_committed: gitCommitResult.committed,
|
|
205
|
+
git_commit_hash: gitCommitResult.hash || undefined,
|
|
206
|
+
elapsed_ms: elapsed,
|
|
207
|
+
atomic_completed: true,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
ctx.output(result, () => {
|
|
211
|
+
console.log(`\n==================================================`);
|
|
212
|
+
console.log(`ā
Shiplens SDK successfully integrated (${elapsed} ms)\n`);
|
|
213
|
+
console.log(`š¦ Project & Dashboard Information`);
|
|
214
|
+
console.log(`Project Name: ${projectName}`);
|
|
215
|
+
if (description) {
|
|
216
|
+
console.log(`Description: ${description}`);
|
|
217
|
+
}
|
|
218
|
+
if (taxonomyFormatted) {
|
|
219
|
+
console.log(`Taxonomy:`);
|
|
220
|
+
console.log(taxonomyFormatted);
|
|
221
|
+
}
|
|
222
|
+
console.log(`App ID: ${appId}`);
|
|
223
|
+
console.log(`Code Injection: Automatically configured in ${injectedFile} (Framework: ${framework})`);
|
|
224
|
+
console.log(`Live Dashboard / Activation URL: š ${dashboardUrl}`);
|
|
225
|
+
console.log(`User Account: ${accountStatusText}`);
|
|
226
|
+
if (gitCommitResult.committed) {
|
|
227
|
+
console.log(`Git Commit: Automatically committed changes (Commit: ${gitCommitResult.hash})`);
|
|
228
|
+
}
|
|
229
|
+
console.log(`š§ AI Skill Ready: ${skillFile}`);
|
|
230
|
+
console.log(`š [SHIPLENS_ATOMIC_COMPLETED] Setup completed atomically. No extra commands needed.`);
|
|
231
|
+
console.log(`==================================================\n`);
|
|
232
|
+
|
|
233
|
+
if (authStatus === 'magic_link_sent' && targetEmail) {
|
|
234
|
+
console.log(`š§ Activation email sent to: ${targetEmail}`);
|
|
235
|
+
console.log(`š Click the link in your email to activate your 50,000 monthly free events quota and authorize this device.`);
|
|
236
|
+
} else {
|
|
237
|
+
console.log(`> [!IMPORTANT]`);
|
|
238
|
+
console.log(`> **Project Pending Activation (Analytics not active yet)**`);
|
|
239
|
+
console.log(`> **Please share your email**: Click the link in the email to complete registration, activate 50,000 free monthly events (~5,000 visitors), and bind your project to your account. Once activated, you can chat directly with me to query and analyze product data.`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { handleInit };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const { getDeviceEnv } = require('../config');
|
|
4
|
+
|
|
5
|
+
function postMcp(url, token, body, sessionId) {
|
|
6
|
+
const target = new URL(url);
|
|
7
|
+
const transport = target.protocol === 'https:' ? https : http;
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const request = transport.request(target, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: {
|
|
12
|
+
Authorization: `Bearer ${token}`,
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
Accept: 'application/json, text/event-stream',
|
|
15
|
+
...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}),
|
|
16
|
+
},
|
|
17
|
+
}, (response) => {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
20
|
+
response.on('end', () => resolve({
|
|
21
|
+
status: response.statusCode || 500,
|
|
22
|
+
contentType: String(response.headers['content-type'] || ''),
|
|
23
|
+
sessionId: String(response.headers['mcp-session-id'] || sessionId || ''),
|
|
24
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
25
|
+
}));
|
|
26
|
+
});
|
|
27
|
+
request.on('error', reject);
|
|
28
|
+
request.end(body);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function emitResponse(result) {
|
|
33
|
+
const lines = result.contentType.includes('text/event-stream')
|
|
34
|
+
? result.body.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim())
|
|
35
|
+
: [result.body];
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
if (line) process.stdout.write(`${line}\n`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function handleMcp(subcommand) {
|
|
42
|
+
if (subcommand !== 'serve') throw new Error('Supported mcp subcommand: serve');
|
|
43
|
+
const deviceEnv = getDeviceEnv();
|
|
44
|
+
if (!deviceEnv) throw new Error('shiplens.env not found. Run shiplens auth bind and complete email activation first.');
|
|
45
|
+
let buffer = '';
|
|
46
|
+
let sessionId = '';
|
|
47
|
+
process.stdin.setEncoding('utf8');
|
|
48
|
+
process.stdin.on('data', async (chunk) => {
|
|
49
|
+
buffer += chunk;
|
|
50
|
+
const lines = buffer.split(/\r?\n/);
|
|
51
|
+
buffer = lines.pop() || '';
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
if (!line.trim()) continue;
|
|
54
|
+
try {
|
|
55
|
+
const result = await postMcp(deviceEnv.SHIPLENS_MCP_URL, deviceEnv.SHIPLENS_MCP_TOKEN, line, sessionId);
|
|
56
|
+
sessionId = result.sessionId;
|
|
57
|
+
emitResponse(result);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: `Shiplens MCP proxy error: ${error.message}` }, id: null })}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { handleMcp, postMcp, emitResponse };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
async function handlePages(args, flags, ctx) {
|
|
2
|
+
const appId = ctx.resolveAppId();
|
|
3
|
+
const range = flags.range || '7d';
|
|
4
|
+
const env = flags.env || 'production';
|
|
5
|
+
const limit = parseInt(flags.limit || '10', 10);
|
|
6
|
+
|
|
7
|
+
const resp = await ctx.client.getPages({ app_id: appId, range, env, limit });
|
|
8
|
+
|
|
9
|
+
ctx.output(resp, () => {
|
|
10
|
+
console.log(`š Page Telemetry Evidence (Range: ${range}, Top ${limit}):\n`);
|
|
11
|
+
const pages = Array.isArray(resp.pages) ? resp.pages : (Array.isArray(resp) ? resp : []);
|
|
12
|
+
if (pages.length === 0) {
|
|
13
|
+
console.log('(No page data found. Verify SDK reporting.)');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
console.log('# | Path / Title | PV | UV | Avg Dwell(s)');
|
|
17
|
+
console.log('--- | ---------------------------------- | ------- | ------- | ------------');
|
|
18
|
+
pages.forEach((p, i) => {
|
|
19
|
+
const path = (p.path || p.template_id || '-').padEnd(34);
|
|
20
|
+
const pv = String(p.pv || p.pageviews || 0).padEnd(7);
|
|
21
|
+
const uv = String(p.uv || p.unique_visitors || 0).padEnd(7);
|
|
22
|
+
const dur = String(p.avg_duration_s || p.avg_session_duration_s || 0).padEnd(12);
|
|
23
|
+
console.log(`[${String(i + 1).padEnd(2)}] | ${path} | ${pv} | ${uv} | ${dur}`);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function handlePaths(args, flags, ctx) {
|
|
29
|
+
const appId = ctx.resolveAppId();
|
|
30
|
+
const range = flags.range || '7d';
|
|
31
|
+
const env = flags.env || 'production';
|
|
32
|
+
|
|
33
|
+
const resp = await ctx.client.queryPaths({ app_id: appId, range, env });
|
|
34
|
+
|
|
35
|
+
ctx.output(resp, () => {
|
|
36
|
+
console.log(`š User Flow & Path Analysis (Range: ${range}):\n`);
|
|
37
|
+
|
|
38
|
+
if (resp.entry_pages && resp.entry_pages.length > 0) {
|
|
39
|
+
console.log('ā¶ Top Entry Pages:');
|
|
40
|
+
resp.entry_pages.slice(0, 5).forEach((e, i) =>
|
|
41
|
+
console.log(` [${i + 1}] ${e.path || e.page} - ${e.sessions || e.count || 0} sessions`)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (resp.exit_pages && resp.exit_pages.length > 0) {
|
|
45
|
+
console.log('\nā Top Exit Pages:');
|
|
46
|
+
resp.exit_pages.slice(0, 5).forEach((e, i) =>
|
|
47
|
+
console.log(` [${i + 1}] ${e.path || e.page} - Exit Rate ${((e.exit_rate || 0) * 100).toFixed(1)}%`)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (resp.paths && resp.paths.length > 0) {
|
|
51
|
+
console.log('\nā High-Frequency Paths:');
|
|
52
|
+
resp.paths.slice(0, 8).forEach((p, i) =>
|
|
53
|
+
console.log(` [${i + 1}] ${p.from || p.source} ā ${p.to || p.target} (${p.count || p.sessions || 0} times)`)
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function handleCanvas(args, flags, ctx) {
|
|
62
|
+
const appId = ctx.resolveAppId();
|
|
63
|
+
const range = flags.range || '7d';
|
|
64
|
+
const env = flags.env || 'production';
|
|
65
|
+
|
|
66
|
+
const resp = await ctx.client.queryPaths({ app_id: appId, range, env, mode: 'canvas' });
|
|
67
|
+
|
|
68
|
+
ctx.output(resp, () => {
|
|
69
|
+
console.log(`šØ User Behavior Canvas Topology (Range: ${range}):\n`);
|
|
70
|
+
const nodeCount = (resp.nodes || []).length;
|
|
71
|
+
const edgeCount = (resp.edges || resp.links || []).length;
|
|
72
|
+
if (nodeCount > 0 || edgeCount > 0) {
|
|
73
|
+
console.log(`š ${nodeCount} nodes, ${edgeCount} edges`);
|
|
74
|
+
if (resp.nodes && resp.nodes.length > 0) {
|
|
75
|
+
console.log('\nMain Nodes:');
|
|
76
|
+
resp.nodes.slice(0, 10).forEach((n, i) =>
|
|
77
|
+
console.log(` [${i + 1}] ${n.label || n.path || n.id} (Visits: ${n.value || n.count || 0})`)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
handlePages,
|
|
88
|
+
handlePaths,
|
|
89
|
+
handleCanvas,
|
|
90
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
|
|
5
|
+
async function handleProjects(subcommand, args, flags, ctx) {
|
|
6
|
+
switch (subcommand) {
|
|
7
|
+
case 'list': {
|
|
8
|
+
const resp = await ctx.client.listProjects();
|
|
9
|
+
ctx.output(resp, () => {
|
|
10
|
+
const projects = resp.projects || [];
|
|
11
|
+
if (projects.length === 0) {
|
|
12
|
+
console.log('No associated projects found. Use shiplens init to create a new project.');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
console.log(`š¦ Found ${projects.length} project(s):\n`);
|
|
16
|
+
projects.forEach((p, i) => {
|
|
17
|
+
console.log(`[${i + 1}] ${p.project_name || p.app_name || 'Untitled'} (${p.app_id})`);
|
|
18
|
+
if (p.description) console.log(` Description: ${p.description}`);
|
|
19
|
+
if (p.taxonomy?.subgenre?.name) {
|
|
20
|
+
const tags = (p.taxonomy.feature_tags || []).map((tag) => tag.name).join(', ');
|
|
21
|
+
console.log(` Taxonomy: ${p.taxonomy.genre?.name || '-'} / ${p.taxonomy.subgenre.name}${tags ? ` - ${tags}` : ''}`);
|
|
22
|
+
}
|
|
23
|
+
console.log(` Industry: ${p.industry || '-'} | Status: ${p.status || 'active'} | Schema Timestamp: ${p.schema_last_modified_at || 0}`);
|
|
24
|
+
if (p.created_at) console.log(` Created: ${p.created_at}`);
|
|
25
|
+
console.log('');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
case 'bind': {
|
|
32
|
+
const appId = ctx.resolveAppId();
|
|
33
|
+
const res = await ctx.client.bindProject(appId, flags.name);
|
|
34
|
+
ctx.output(res, () => {
|
|
35
|
+
console.log(`ā
Project ${appId} bound to current account successfully.`);
|
|
36
|
+
});
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case 'delete': {
|
|
41
|
+
const appId = ctx.resolveAppId();
|
|
42
|
+
if (!flags.force) {
|
|
43
|
+
console.log(`ā ļø Warning: Deleting project ${appId} and all historical event data. This action cannot be undone!`);
|
|
44
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
const confirmed = await new Promise((resolve) => {
|
|
46
|
+
rl.question(`Enter App ID or 'yes' to confirm deletion: `, (ans) => {
|
|
47
|
+
rl.close();
|
|
48
|
+
resolve(ans.trim() === 'yes' || ans.trim() === appId);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
if (!confirmed) {
|
|
52
|
+
console.log('š Operation cancelled.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const res = await ctx.client.deleteProject(appId);
|
|
58
|
+
|
|
59
|
+
// Clean up local business context file
|
|
60
|
+
const localContextPath = path.join(process.cwd(), '.shiplens', 'contexts', `${appId}.md`);
|
|
61
|
+
if (fs.existsSync(localContextPath)) {
|
|
62
|
+
try { fs.unlinkSync(localContextPath); } catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ctx.output(res, () => {
|
|
66
|
+
console.log(`šļø Project ${appId} deleted successfully.`);
|
|
67
|
+
});
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
default:
|
|
72
|
+
throw new Error(`Unknown projects subcommand: ${subcommand}. Supported: list, bind, delete`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { handleProjects };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
async function handleQuery(args, flags, ctx) {
|
|
4
|
+
const appId = ctx.resolveAppId();
|
|
5
|
+
const range = flags.range || '7d';
|
|
6
|
+
const grain = flags.grain || 'day';
|
|
7
|
+
const groupBy = flags['group-by'] || '';
|
|
8
|
+
const limit = parseInt(flags.limit || '30', 10);
|
|
9
|
+
const chartType = flags['chart-type'] || 'line';
|
|
10
|
+
const env = flags.env || 'production';
|
|
11
|
+
|
|
12
|
+
let queryReq;
|
|
13
|
+
|
|
14
|
+
// Support --file: read full AnalyticsQueryRequest from JSON file
|
|
15
|
+
if (flags.file) {
|
|
16
|
+
if (!fs.existsSync(flags.file)) {
|
|
17
|
+
const err = new Error(`Query config file not found: ${flags.file}`);
|
|
18
|
+
err.code = 'INVALID_ARGS';
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
queryReq = JSON.parse(fs.readFileSync(flags.file, 'utf8'));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
const err = new Error(`Failed to parse query config file (${flags.file}): ${e.message}`);
|
|
25
|
+
err.code = 'INVALID_ARGS';
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
if (!queryReq.range) queryReq.range = range;
|
|
29
|
+
if (!queryReq.env) queryReq.env = env;
|
|
30
|
+
} else {
|
|
31
|
+
// Support --metrics (comma-separated) or --metric
|
|
32
|
+
const metricsRaw = flags.metrics || flags.metric || 'pageviews';
|
|
33
|
+
const metricList = String(metricsRaw).split(',').map((m) => m.trim()).filter(Boolean);
|
|
34
|
+
|
|
35
|
+
// Parse --filter key=val
|
|
36
|
+
const filters = {};
|
|
37
|
+
const filterList = Array.isArray(flags.filter)
|
|
38
|
+
? flags.filter
|
|
39
|
+
: flags.filter ? [flags.filter] : [];
|
|
40
|
+
for (const f of filterList) {
|
|
41
|
+
const [k, ...v] = f.split('=');
|
|
42
|
+
if (k && v.length > 0) filters[k.trim()] = v.join('=').trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
queryReq = {
|
|
46
|
+
env,
|
|
47
|
+
range,
|
|
48
|
+
panels: metricList.map((metric, idx) => ({
|
|
49
|
+
panel_id: `panel_cli_${idx}`,
|
|
50
|
+
title: `${metric} Trend`,
|
|
51
|
+
metric,
|
|
52
|
+
chart_type: chartType,
|
|
53
|
+
time_grain: grain,
|
|
54
|
+
group_by: groupBy || undefined,
|
|
55
|
+
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
56
|
+
limit,
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const resp = await ctx.client.query(appId, queryReq);
|
|
62
|
+
|
|
63
|
+
ctx.output(resp, () => {
|
|
64
|
+
const metricNames = (queryReq.panels || []).map((p) => p.metric).join(', ');
|
|
65
|
+
console.log(`š Query Metrics: ${metricNames} | Range: ${queryReq.range}`);
|
|
66
|
+
if (resp.data && resp.data.length > 0) {
|
|
67
|
+
resp.data.forEach((row) => console.log('ā¢', JSON.stringify(row)));
|
|
68
|
+
} else if (resp.panels && resp.panels.length > 0) {
|
|
69
|
+
resp.panels.forEach((p) => {
|
|
70
|
+
console.log(`\nš Panel: ${p.title || p.panel_id}`);
|
|
71
|
+
if (Array.isArray(p.data)) {
|
|
72
|
+
p.data.forEach((row) => console.log(' ā¢', JSON.stringify(row)));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
} else {
|
|
76
|
+
console.log('(No matching data found)');
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { handleQuery };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
async function handleSQL(args, flags, ctx) {
|
|
2
|
+
const appId = ctx.resolveAppId();
|
|
3
|
+
let queryStr = flags.query || flags.sql || args.join(' ');
|
|
4
|
+
|
|
5
|
+
// Support --stdin for piped SQL execution
|
|
6
|
+
if (flags.stdin || flags['read-stdin']) {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
for await (const chunk of process.stdin) {
|
|
9
|
+
chunks.push(chunk);
|
|
10
|
+
}
|
|
11
|
+
const piped = Buffer.concat(chunks).toString('utf8').trim();
|
|
12
|
+
if (piped) queryStr = piped;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!queryStr) {
|
|
16
|
+
const err = new Error('SQL query string cannot be empty. Pass --query "SELECT ..." or pipe via --stdin.');
|
|
17
|
+
err.code = 'INVALID_SQL';
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resp = await ctx.client.executeSQL({ app_id: appId, query: queryStr });
|
|
22
|
+
|
|
23
|
+
ctx.output(resp, () => {
|
|
24
|
+
const rowCount = resp.row_count !== undefined ? resp.row_count : (resp.rows ? resp.rows.length : 0);
|
|
25
|
+
console.log(`š SQL executed successfully (${resp.elapsed_ms || 0} ms, ${rowCount} rows returned):\n`);
|
|
26
|
+
|
|
27
|
+
if (resp.columns && resp.columns.length > 0) {
|
|
28
|
+
const colWidths = resp.columns.map((col) => {
|
|
29
|
+
let maxW = col.length;
|
|
30
|
+
if (resp.rows) {
|
|
31
|
+
resp.rows.forEach((row) => {
|
|
32
|
+
const cell = row[resp.columns.indexOf(col)];
|
|
33
|
+
const cellStr = cell === null || cell === undefined ? 'NULL' : String(cell);
|
|
34
|
+
if (cellStr.length > maxW) maxW = cellStr.length;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return maxW;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const header = resp.columns.map((col, i) => col.padEnd(colWidths[i])).join(' | ');
|
|
41
|
+
const separator = colWidths.map((w) => '-'.repeat(w)).join('-+-');
|
|
42
|
+
console.log(header);
|
|
43
|
+
console.log(separator);
|
|
44
|
+
|
|
45
|
+
if (resp.rows && resp.rows.length > 0) {
|
|
46
|
+
resp.rows.forEach((row) => {
|
|
47
|
+
const line = resp.columns.map((_, i) => {
|
|
48
|
+
const cell = row[i];
|
|
49
|
+
const cellStr = cell === null || cell === undefined ? 'NULL' : String(cell);
|
|
50
|
+
return cellStr.padEnd(colWidths[i]);
|
|
51
|
+
}).join(' | ');
|
|
52
|
+
console.log(line);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
} else if (resp.rows && resp.rows.length > 0) {
|
|
56
|
+
resp.rows.forEach((row) => console.log(row.join('\t|\t')));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { handleSQL };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
async function handleSummary(args, flags, ctx) {
|
|
2
|
+
const appId = ctx.resolveAppId();
|
|
3
|
+
const range = flags.range || '7d';
|
|
4
|
+
const env = flags.env || 'production';
|
|
5
|
+
|
|
6
|
+
const resp = await ctx.client.summary(appId, range, env);
|
|
7
|
+
|
|
8
|
+
ctx.output(resp, () => {
|
|
9
|
+
console.log(`š Core Product Summary (Range: ${range}):`);
|
|
10
|
+
console.log(`⢠Total Pageviews (PV): ${resp.total_pv || 0}`);
|
|
11
|
+
console.log(`⢠Unique Visitors (UV): ${resp.total_uv || 0}`);
|
|
12
|
+
console.log(`⢠Avg Session Duration: ${resp.avg_session_duration_s || 0} s`);
|
|
13
|
+
console.log(`⢠Bounce Rate: ${((resp.bounce_rate || 0) * 100).toFixed(1)}%`);
|
|
14
|
+
if (resp.top_countries && resp.top_countries.length > 0) {
|
|
15
|
+
console.log('⢠Top Countries:', resp.top_countries);
|
|
16
|
+
}
|
|
17
|
+
if (resp.top_devices && resp.top_devices.length > 0) {
|
|
18
|
+
console.log('⢠Top Devices:', resp.top_devices);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { handleSummary };
|