@prasenjeet/shipli 1.1.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 +59 -3
- package/package.json +3 -2
- package/src/index.js +17 -3
- package/src/mcp-server.js +34 -5
- 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.
|
|
@@ -146,9 +148,41 @@ Use `shipli config` to update settings anytime.
|
|
|
146
148
|
|
|
147
149
|
Shipli includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server, so AI coding assistants like Claude Code, Cursor, and Windsurf can run audits directly inside your editor.
|
|
148
150
|
|
|
149
|
-
###
|
|
151
|
+
### Step 1 — Install globally
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
npm install -g @prasenjeet/shipli
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Verify: `shipli --version` and `which shipli-mcp`.
|
|
158
|
+
|
|
159
|
+
### Step 2 — Add the MCP server
|
|
160
|
+
|
|
161
|
+
**Option A: Claude Code CLI (recommended)**
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Using Claude as the AI provider
|
|
165
|
+
claude mcp add shipli \
|
|
166
|
+
--transport stdio \
|
|
167
|
+
--env SHIPLI_PROVIDER=claude \
|
|
168
|
+
--env SHIPLI_MODEL=claude-haiku-4-5 \
|
|
169
|
+
--env ANTHROPIC_API_KEY=your-api-key \
|
|
170
|
+
-- shipli-mcp
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Using Gemini as the AI provider
|
|
175
|
+
claude mcp add shipli \
|
|
176
|
+
--transport stdio \
|
|
177
|
+
--env SHIPLI_PROVIDER=gemini \
|
|
178
|
+
--env SHIPLI_MODEL=gemini-2.5-flash \
|
|
179
|
+
--env GEMINI_API_KEY=your-api-key \
|
|
180
|
+
-- shipli-mcp
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Option B: Manual JSON config**
|
|
150
184
|
|
|
151
|
-
Add to
|
|
185
|
+
Add to `~/.claude/.mcp.json`:
|
|
152
186
|
|
|
153
187
|
```json
|
|
154
188
|
{
|
|
@@ -165,7 +199,11 @@ Add to your MCP config (e.g. `~/.claude/.mcp.json`):
|
|
|
165
199
|
}
|
|
166
200
|
```
|
|
167
201
|
|
|
168
|
-
For
|
|
202
|
+
For Cursor/Windsurf, add the same config to your editor's MCP settings.
|
|
203
|
+
|
|
204
|
+
### Step 3 — Restart and verify
|
|
205
|
+
|
|
206
|
+
Restart Claude Code (or run `/mcp` in chat) to pick up the new server. You should see `shipli` listed with its two tools.
|
|
169
207
|
|
|
170
208
|
### Available Tools
|
|
171
209
|
|
|
@@ -197,6 +235,24 @@ The assistant will call the appropriate Shipli MCP tool and return the results i
|
|
|
197
235
|
|
|
198
236
|
The CLI exits with code `1` on failure, making it easy to gate deployments.
|
|
199
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
|
+
|
|
200
256
|
## License
|
|
201
257
|
|
|
202
258
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prasenjeet/shipli",
|
|
3
|
-
"version": "1.1
|
|
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
|
|
|
@@ -66,12 +67,13 @@ const server = new McpServer({
|
|
|
66
67
|
|
|
67
68
|
server.tool(
|
|
68
69
|
'shipli_store_audit',
|
|
69
|
-
'Run a store compliance audit on a Flutter project against Apple App Store and/or Google Play guidelines. Returns structured
|
|
70
|
+
'Run a store compliance audit on a Flutter project against Apple App Store and/or Google Play guidelines. Scans Dart source files, pubspec.yaml, Info.plist (iOS), and AndroidManifest.xml (Android). Returns structured JSON with PASS/WARNING/FAIL scores, specific guideline citations, and actionable fix suggestions. Supports both Flutter apps and packages.',
|
|
70
71
|
{
|
|
71
|
-
projectDir: z.string().describe('Absolute path to the Flutter project root directory'),
|
|
72
|
-
platform: z.enum(['ios', 'android', 'both']).optional().describe('Target platform. Auto-detected from
|
|
72
|
+
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"'),
|
|
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,
|
|
@@ -140,11 +155,12 @@ server.tool(
|
|
|
140
155
|
|
|
141
156
|
server.tool(
|
|
142
157
|
'shipli_code_review',
|
|
143
|
-
'Run a code quality and security review on a Flutter project. Checks architecture, error handling, performance,
|
|
158
|
+
'Run a code quality and security review on a Flutter project. Scans all Dart source files and pubspec.yaml. Checks architecture patterns, error handling, performance issues, dependency hygiene, and security vulnerabilities. Returns structured JSON with findings, severity levels, and fix suggestions. Supports both Flutter apps and packages (including example/ directories).',
|
|
144
159
|
{
|
|
145
|
-
projectDir: z.string().describe('Absolute path to the Flutter project root directory'),
|
|
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
|
+
}
|