@prasenjeet/shipli 1.2.0 → 1.2.1
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 +20 -0
- package/package.json +3 -2
- package/src/index.js +17 -3
- package/src/mcp-server.js +29 -0
- package/src/telemetry.js +83 -0
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@prasenjeet/shipli)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
|
+
**Website:** [https://prasenjeet-symon.github.io/shipli-ai/](https://prasenjeet-symon.github.io/shipli-ai/)
|
|
7
|
+
|
|
6
8
|
Store rejections cost days of development time. **Shipli** is a CLI tool that audits your Flutter source code against **Apple App Store** and **Google Play** guidelines using AI.
|
|
7
9
|
|
|
8
10
|
Catch missing permissions, policy violations, and compliance issues before you submit.
|
|
@@ -233,6 +235,24 @@ The assistant will call the appropriate Shipli MCP tool and return the results i
|
|
|
233
235
|
|
|
234
236
|
The CLI exits with code `1` on failure, making it easy to gate deployments.
|
|
235
237
|
|
|
238
|
+
## Telemetry
|
|
239
|
+
|
|
240
|
+
Shipli collects anonymous usage metrics to help improve the tool. No project data, file contents, API keys, or personally identifiable information is ever collected.
|
|
241
|
+
|
|
242
|
+
**What's collected:** audit mode, platform, provider, model, pass/fail score, duration, token counts, OS, and CLI version.
|
|
243
|
+
|
|
244
|
+
**Opt out** using any of these methods:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
# Environment variable
|
|
248
|
+
export SHIPLI_TELEMETRY=off
|
|
249
|
+
|
|
250
|
+
# Or use the Do Not Track standard
|
|
251
|
+
export DO_NOT_TRACK=1
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Or add `"telemetry": false` to your `~/.shipli` config file.
|
|
255
|
+
|
|
236
256
|
## License
|
|
237
257
|
|
|
238
258
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prasenjeet/shipli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "AI-powered App Store review audit for Flutter projects. Catch rejections before you submit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
],
|
|
29
29
|
"author": "",
|
|
30
30
|
"license": "MIT",
|
|
31
|
+
"homepage": "https://prasenjeet-symon.github.io/shipli-ai/",
|
|
31
32
|
"repository": {
|
|
32
33
|
"type": "git",
|
|
33
|
-
"url": ""
|
|
34
|
+
"url": "https://github.com/prasenjeet-symon/shipli-ai"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
37
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { audit } from './auditor.js';
|
|
|
18
18
|
import { fetchGuidelines } from './guidelines.js';
|
|
19
19
|
import { print as printReport } from './reporter.js';
|
|
20
20
|
import { PROVIDER_DEFAULTS as DEFAULTS, detectPlatform } from './defaults.js';
|
|
21
|
+
import { trackEvent } from './telemetry.js';
|
|
21
22
|
|
|
22
23
|
// Read package version
|
|
23
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -113,6 +114,7 @@ program
|
|
|
113
114
|
|
|
114
115
|
const platformLabel = { ios: 'iOS', android: 'Android', both: 'iOS + Android' }[platform];
|
|
115
116
|
let spinner = ora({ text: `Scanning Flutter project (${platformLabel})...`, color: 'cyan' }).start();
|
|
117
|
+
const startTime = Date.now();
|
|
116
118
|
|
|
117
119
|
try {
|
|
118
120
|
// 1. Read pubspec.yaml first (needed for type detection)
|
|
@@ -205,11 +207,23 @@ program
|
|
|
205
207
|
platform,
|
|
206
208
|
});
|
|
207
209
|
|
|
208
|
-
// 9.
|
|
209
|
-
|
|
210
|
+
// 9. Track and exit
|
|
211
|
+
trackEvent('audit_completed', {
|
|
212
|
+
source: 'cli', mode, platform, provider, model,
|
|
213
|
+
project_type: projectType, score: result.score,
|
|
214
|
+
duration_ms: Date.now() - startTime,
|
|
215
|
+
tokens_input: result._tokens?.actual?.input ?? null,
|
|
216
|
+
tokens_output: result._tokens?.actual?.output ?? null,
|
|
217
|
+
});
|
|
218
|
+
process.exitCode = result.score === 'FAIL' ? 1 : 0;
|
|
210
219
|
} catch (err) {
|
|
211
220
|
spinner.fail(chalk.red(err.message));
|
|
212
|
-
|
|
221
|
+
trackEvent('audit_error', {
|
|
222
|
+
source: 'cli', mode, platform, provider, model,
|
|
223
|
+
duration_ms: Date.now() - startTime,
|
|
224
|
+
error_message: err.message.slice(0, 200),
|
|
225
|
+
});
|
|
226
|
+
process.exitCode = 1;
|
|
213
227
|
}
|
|
214
228
|
});
|
|
215
229
|
|
package/src/mcp-server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { fetchGuidelines } from './guidelines.js';
|
|
|
15
15
|
import { audit } from './auditor.js';
|
|
16
16
|
import { loadConfig } from './config.js';
|
|
17
17
|
import { PROVIDER_DEFAULTS, detectPlatform } from './defaults.js';
|
|
18
|
+
import { trackEvent } from './telemetry.js';
|
|
18
19
|
|
|
19
20
|
// ── Read package version ──
|
|
20
21
|
|
|
@@ -72,6 +73,7 @@ server.tool(
|
|
|
72
73
|
platform: z.enum(['ios', 'android', 'both']).optional().describe('Target platform: "ios" (App Store only), "android" (Play Store only), or "both" (both stores). Auto-detected from ios/ and android/ directory presence if omitted.'),
|
|
73
74
|
},
|
|
74
75
|
async ({ projectDir, platform }) => {
|
|
76
|
+
const startTime = Date.now();
|
|
75
77
|
try {
|
|
76
78
|
const dir = validateProject(projectDir);
|
|
77
79
|
const { provider, model, apiKey } = resolveConfig(dir);
|
|
@@ -124,10 +126,23 @@ server.tool(
|
|
|
124
126
|
{ apiKey, model, provider, mode: 'store', platform: resolvedPlatform },
|
|
125
127
|
);
|
|
126
128
|
|
|
129
|
+
trackEvent('audit_completed', {
|
|
130
|
+
source: 'mcp', mode: 'store', platform: resolvedPlatform,
|
|
131
|
+
provider, model, project_type: projectType, score: result.score,
|
|
132
|
+
duration_ms: Date.now() - startTime,
|
|
133
|
+
tokens_input: result._tokens?.actual?.input ?? null,
|
|
134
|
+
tokens_output: result._tokens?.actual?.output ?? null,
|
|
135
|
+
});
|
|
136
|
+
|
|
127
137
|
return {
|
|
128
138
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
129
139
|
};
|
|
130
140
|
} catch (err) {
|
|
141
|
+
trackEvent('audit_error', {
|
|
142
|
+
source: 'mcp', mode: 'store',
|
|
143
|
+
duration_ms: Date.now() - startTime,
|
|
144
|
+
error_message: err.message.slice(0, 200),
|
|
145
|
+
});
|
|
131
146
|
return {
|
|
132
147
|
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
133
148
|
isError: true,
|
|
@@ -145,6 +160,7 @@ server.tool(
|
|
|
145
160
|
projectDir: z.string().describe('Absolute path to the Flutter project root directory. Must contain a pubspec.yaml and a lib/ folder. Example: "/Users/you/projects/my-flutter-app"'),
|
|
146
161
|
},
|
|
147
162
|
async ({ projectDir }) => {
|
|
163
|
+
const startTime = Date.now();
|
|
148
164
|
try {
|
|
149
165
|
const dir = validateProject(projectDir);
|
|
150
166
|
const { provider, model, apiKey } = resolveConfig(dir);
|
|
@@ -176,10 +192,23 @@ server.tool(
|
|
|
176
192
|
{ apiKey, model, provider, mode: 'code', platform: 'both' },
|
|
177
193
|
);
|
|
178
194
|
|
|
195
|
+
trackEvent('audit_completed', {
|
|
196
|
+
source: 'mcp', mode: 'code', platform: 'both',
|
|
197
|
+
provider, model, project_type: projectType, score: result.score,
|
|
198
|
+
duration_ms: Date.now() - startTime,
|
|
199
|
+
tokens_input: result._tokens?.actual?.input ?? null,
|
|
200
|
+
tokens_output: result._tokens?.actual?.output ?? null,
|
|
201
|
+
});
|
|
202
|
+
|
|
179
203
|
return {
|
|
180
204
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
181
205
|
};
|
|
182
206
|
} catch (err) {
|
|
207
|
+
trackEvent('audit_error', {
|
|
208
|
+
source: 'mcp', mode: 'code',
|
|
209
|
+
duration_ms: Date.now() - startTime,
|
|
210
|
+
error_message: err.message.slice(0, 200),
|
|
211
|
+
});
|
|
183
212
|
return {
|
|
184
213
|
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
185
214
|
isError: true,
|
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { hostname, userInfo, platform, arch } from 'node:os';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
11
|
+
|
|
12
|
+
const POSTHOG_ENDPOINT = 'https://us.i.posthog.com/capture';
|
|
13
|
+
const POSTHOG_API_KEY = 'phc_JcSoYrxMXvJjCtlePCFI6UuhNbwq33WXNeUKmDB6Msz';
|
|
14
|
+
|
|
15
|
+
// ── Opt-out check (lazy, cached) ──
|
|
16
|
+
|
|
17
|
+
let _disabled = null;
|
|
18
|
+
|
|
19
|
+
function isDisabled() {
|
|
20
|
+
if (_disabled !== null) return _disabled;
|
|
21
|
+
|
|
22
|
+
const envVal = (process.env.SHIPLI_TELEMETRY || '').toLowerCase();
|
|
23
|
+
if (['off', 'false', '0', 'no'].includes(envVal)) {
|
|
24
|
+
_disabled = true;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (process.env.DO_NOT_TRACK === '1') {
|
|
29
|
+
_disabled = true;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(join(homedir(), '.shipli'), 'utf-8');
|
|
35
|
+
const config = JSON.parse(raw);
|
|
36
|
+
if (config.telemetry === false) {
|
|
37
|
+
_disabled = true;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// No config file or invalid JSON — telemetry stays on
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_disabled = false;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Anonymous device ID ──
|
|
49
|
+
|
|
50
|
+
function getAnonymousId() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = `${hostname()}:${userInfo().username}`;
|
|
53
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
54
|
+
} catch {
|
|
55
|
+
return createHash('sha256').update(hostname()).digest('hex').slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Fire-and-forget event dispatch ──
|
|
60
|
+
|
|
61
|
+
export function trackEvent(name, properties = {}) {
|
|
62
|
+
if (isDisabled()) return;
|
|
63
|
+
|
|
64
|
+
const payload = {
|
|
65
|
+
api_key: POSTHOG_API_KEY,
|
|
66
|
+
event: name,
|
|
67
|
+
distinct_id: getAnonymousId(),
|
|
68
|
+
properties: {
|
|
69
|
+
...properties,
|
|
70
|
+
cli_version: pkg.version,
|
|
71
|
+
node_version: process.version,
|
|
72
|
+
os: platform(),
|
|
73
|
+
arch: arch(),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
fetch(POSTHOG_ENDPOINT, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify(payload),
|
|
81
|
+
signal: AbortSignal.timeout(3000),
|
|
82
|
+
}).catch(() => {});
|
|
83
|
+
}
|