servicemap 0.1.0

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.
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
8
+ const skillRoot = resolve(scriptDir, '..');
9
+ const logoDatabasePath = resolve(skillRoot, 'assets/service-logo-database.json');
10
+ const defaultOutput = '.servicemap/internal-servicemap.json';
11
+
12
+ const ignoredDirs = new Set([
13
+ '.git',
14
+ '.next',
15
+ '.nuxt',
16
+ '.svelte-kit',
17
+ 'build',
18
+ 'coverage',
19
+ 'dist',
20
+ 'node_modules',
21
+ 'target',
22
+ 'vendor',
23
+ ]);
24
+
25
+ const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.astro', '.vue', '.svelte', '.py', '.rb', '.go', '.rs', '.php', '.java']);
26
+ const configFileNames = new Set([
27
+ 'Dockerfile',
28
+ 'docker-compose.yml',
29
+ 'firebase.json',
30
+ 'netlify.toml',
31
+ 'package.json',
32
+ 'render.yaml',
33
+ 'supabase.config.toml',
34
+ 'tauri.conf.json',
35
+ 'vercel.json',
36
+ 'wrangler.toml',
37
+ ]);
38
+
39
+ const manualRules = [
40
+ service('Stripe', 'stripe', 'billing', ['stripe'], ['STRIPE_'], ['stripe.com']),
41
+ service('Supabase', 'supabase', 'database', ['@supabase/supabase-js', 'supabase'], ['SUPABASE_'], ['supabase.com']),
42
+ service('Firebase', 'firebase', 'cloud', ['firebase', 'firebase-admin'], ['FIREBASE_', 'GOOGLE_APPLICATION_CREDENTIALS'], ['firebase.google.com']),
43
+ service('Sentry', 'sentry', 'monitoring', ['@sentry/react', '@sentry/nextjs', '@sentry/node', 'sentry'], ['SENTRY_'], ['sentry.io']),
44
+ service('PostHog', 'posthog', 'analytics', ['posthog-js', 'posthog-node', 'posthog'], ['POSTHOG_'], ['posthog.com']),
45
+ service('OpenAI', 'openai', 'ai', ['openai'], ['OPENAI_'], ['openai.com', 'api.openai.com']),
46
+ service('Anthropic', 'anthropic', 'ai', ['@anthropic-ai/sdk', 'anthropic'], ['ANTHROPIC_'], ['anthropic.com']),
47
+ service('Vercel', 'vercel', 'hosting', ['@vercel/analytics', '@vercel/speed-insights'], ['VERCEL_'], ['vercel.com']),
48
+ service('Cloudflare', 'cloudflare', 'cloud', ['wrangler', '@cloudflare/workers-types'], ['CLOUDFLARE_'], ['cloudflare.com']),
49
+ service('Resend', 'resend', 'email', ['resend'], ['RESEND_'], ['resend.com']),
50
+ service('Mailgun', 'mailgun', 'email', ['mailgun.js'], ['MAILGUN_'], ['mailgun.com']),
51
+ service('SendGrid', 'sendgrid', 'email', ['@sendgrid/mail', '@sendgrid/client'], ['SENDGRID_'], ['sendgrid.com']),
52
+ service('Clerk', 'clerk', 'auth', ['@clerk/nextjs', '@clerk/clerk-react'], ['CLERK_', 'NEXT_PUBLIC_CLERK_'], ['clerk.com']),
53
+ service('Auth0', 'auth0', 'auth', ['@auth0/auth0-react', '@auth0/nextjs-auth0'], ['AUTH0_'], ['auth0.com']),
54
+ service('GitHub', 'github', 'developer-tools', ['@octokit/rest', '@actions/core'], ['GITHUB_'], ['github.com']),
55
+ service('Slack', 'slack', 'collaboration', ['@slack/web-api', '@slack/bolt'], ['SLACK_'], ['slack.com']),
56
+ service('Discord', 'discord', 'collaboration', ['discord.js'], ['DISCORD_'], ['discord.com']),
57
+ service('HubSpot', 'hubspot', 'crm', ['@hubspot/api-client'], ['HUBSPOT_'], ['hubspot.com']),
58
+ service('Zendesk', 'zendesk', 'support', ['node-zendesk', 'zendesk'], ['ZENDESK_'], ['zendesk.com']),
59
+ service('Intercom', 'intercom', 'support', ['@intercom/messenger-js-sdk'], ['INTERCOM_'], ['intercom.com']),
60
+ ];
61
+
62
+ function service(name, key, category, packages, envPrefixes, domains) {
63
+ return { name, key, category, packages, envPrefixes, domains };
64
+ }
65
+
66
+ function parseArgs() {
67
+ const args = process.argv.slice(2);
68
+ const options = { root: process.cwd(), output: defaultOutput };
69
+ for (let index = 0; index < args.length; index += 1) {
70
+ if (args[index] === '--root') options.root = args[index + 1] ?? options.root;
71
+ if (args[index] === '--output') options.output = args[index + 1] ?? options.output;
72
+ }
73
+ options.root = resolve(options.root);
74
+ options.output = resolve(options.root, options.output);
75
+ return options;
76
+ }
77
+
78
+ async function readJson(path) {
79
+ try {
80
+ return JSON.parse(await readFile(path, 'utf8'));
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ async function walk(root, files = []) {
87
+ for (const entry of await readdir(root, { withFileTypes: true })) {
88
+ if (ignoredDirs.has(entry.name)) continue;
89
+ const path = join(root, entry.name);
90
+ if (entry.isDirectory()) {
91
+ await walk(path, files);
92
+ } else if (entry.isFile()) {
93
+ files.push(path);
94
+ }
95
+ }
96
+ return files;
97
+ }
98
+
99
+ function packageNameFromImport(value) {
100
+ if (!value || value.startsWith('.') || value.startsWith('/')) return null;
101
+ if (value.startsWith('@')) return value.split('/').slice(0, 2).join('/');
102
+ return value.split('/')[0];
103
+ }
104
+
105
+ function envNamesFromText(text) {
106
+ return text
107
+ .split(/\r?\n/)
108
+ .map((line) => line.trim())
109
+ .filter((line) => line && !line.startsWith('#'))
110
+ .map((line) => line.match(/^([A-Z0-9_]+)\s*=/)?.[1])
111
+ .filter(Boolean);
112
+ }
113
+
114
+ function urlHostsFromText(text) {
115
+ return [...text.matchAll(/https?:\/\/[^\s"'`)<\]}]+/g)]
116
+ .map((match) => {
117
+ try {
118
+ return new URL(match[0]).hostname.replace(/^www\./, '');
119
+ } catch {
120
+ return null;
121
+ }
122
+ })
123
+ .filter(Boolean);
124
+ }
125
+
126
+ function importsFromText(text) {
127
+ const imports = [
128
+ ...text.matchAll(/from\s+['"]([^'"]+)['"]/g),
129
+ ...text.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g),
130
+ ...text.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g),
131
+ ];
132
+ return imports.map((match) => packageNameFromImport(match[1])).filter(Boolean);
133
+ }
134
+
135
+ function detectFramework(root, packageJson, files) {
136
+ const dependencies = allDependencies(packageJson);
137
+ if (existsSync(join(root, 'next.config.js')) || existsSync(join(root, 'next.config.mjs')) || dependencies.has('next')) {
138
+ return existsSync(join(root, 'app')) || existsSync(join(root, 'src/app')) ? 'next-app-router' : 'next-pages-router';
139
+ }
140
+ if (existsSync(join(root, 'astro.config.mjs')) || dependencies.has('astro')) return 'astro';
141
+ if (dependencies.has('@remix-run/react') || files.some((file) => file.includes('/app/routes/'))) return 'remix';
142
+ if (dependencies.has('vite') || existsSync(join(root, 'vite.config.ts')) || existsSync(join(root, 'vite.config.js'))) return 'vite';
143
+ return 'static';
144
+ }
145
+
146
+ function allDependencies(packageJson) {
147
+ return new Set(Object.keys({
148
+ ...(packageJson?.dependencies ?? {}),
149
+ ...(packageJson?.devDependencies ?? {}),
150
+ ...(packageJson?.peerDependencies ?? {}),
151
+ ...(packageJson?.optionalDependencies ?? {}),
152
+ }));
153
+ }
154
+
155
+ function addEvidence(map, key, service, evidence) {
156
+ const existing = map.get(key) ?? {
157
+ id: key,
158
+ name: service.name,
159
+ key,
160
+ category: service.category,
161
+ evidence: [],
162
+ confidence: 'medium',
163
+ iconUrl: service.iconUrl,
164
+ };
165
+ if (!existing.evidence.some((item) => item.type === evidence.type && item.value === evidence.value)) {
166
+ existing.evidence.push(evidence);
167
+ }
168
+ if (evidence.confidence === 'high') existing.confidence = 'high';
169
+ map.set(key, existing);
170
+ }
171
+
172
+ function buildGeneratedRules(database) {
173
+ return database.map((entry) => ({
174
+ name: entry.name,
175
+ key: entry.key,
176
+ category: entry.category,
177
+ iconUrl: entry.iconUrl,
178
+ packages: [entry.key, entry.name.toLowerCase().replace(/\s+/g, ''), ...entry.aliases.map((alias) => alias.toLowerCase())].filter(Boolean),
179
+ envPrefixes: [entry.key.replace(/[^a-z0-9]/gi, '_').toUpperCase()],
180
+ domains: [entry.sourceHost].filter(Boolean),
181
+ }));
182
+ }
183
+
184
+ function matchServices({ dependencies, imports, envNames, hosts, configFiles, database }) {
185
+ const serviceMap = new Map();
186
+ const databaseByKey = new Map(database.map((entry) => [entry.key, entry]));
187
+ const enrichedManualRules = manualRules.map((rule) => ({
188
+ ...rule,
189
+ iconUrl: databaseByKey.get(rule.key)?.iconUrl,
190
+ }));
191
+ const rules = [...enrichedManualRules, ...buildGeneratedRules(database)];
192
+ const dependencySet = new Set([...dependencies, ...imports]);
193
+
194
+ for (const rule of rules) {
195
+ for (const packageName of rule.packages) {
196
+ if (!packageName) continue;
197
+ const matchedPackage = [...dependencySet].find((dependency) => dependency === packageName || dependency.startsWith(`${packageName}/`));
198
+ if (matchedPackage) addEvidence(serviceMap, rule.key, rule, { type: 'package', value: matchedPackage, confidence: 'high' });
199
+ }
200
+
201
+ for (const prefix of rule.envPrefixes) {
202
+ const matchedEnv = envNames.find((name) => name === prefix || name.startsWith(prefix));
203
+ if (matchedEnv) addEvidence(serviceMap, rule.key, rule, { type: 'environment-variable-name', value: matchedEnv, confidence: 'medium' });
204
+ }
205
+
206
+ for (const domain of rule.domains) {
207
+ const matchedHost = hosts.find((host) => host === domain || host.endsWith(`.${domain}`));
208
+ if (matchedHost) addEvidence(serviceMap, rule.key, rule, { type: 'external-url-host', value: matchedHost, confidence: 'medium' });
209
+ }
210
+ }
211
+
212
+ if (configFiles.includes('firebase.json')) addEvidence(serviceMap, 'firebase', enrichedManualRules.find((rule) => rule.key === 'firebase'), { type: 'config-file', value: 'firebase.json', confidence: 'high' });
213
+ if (configFiles.includes('vercel.json')) addEvidence(serviceMap, 'vercel', enrichedManualRules.find((rule) => rule.key === 'vercel'), { type: 'config-file', value: 'vercel.json', confidence: 'high' });
214
+ if (configFiles.includes('wrangler.toml')) addEvidence(serviceMap, 'cloudflare', enrichedManualRules.find((rule) => rule.key === 'cloudflare'), { type: 'config-file', value: 'wrangler.toml', confidence: 'high' });
215
+
216
+ return [...serviceMap.values()].map((service) => ({
217
+ ...service,
218
+ evidence: service.evidence.slice(0, 8),
219
+ })).sort((a, b) => a.name.localeCompare(b.name));
220
+ }
221
+
222
+ function buildEdges(projectName, services) {
223
+ return services.map((service) => ({
224
+ source: 'project',
225
+ target: service.id,
226
+ label: service.confidence === 'high' ? 'uses' : 'may use',
227
+ }));
228
+ }
229
+
230
+ async function main() {
231
+ const options = parseArgs();
232
+ const packageJsonPath = join(options.root, 'package.json');
233
+ const packageJson = await readJson(packageJsonPath);
234
+ const database = await readJson(logoDatabasePath);
235
+ const files = await walk(options.root);
236
+ const projectName = packageJson?.name ?? basename(options.root);
237
+ const dependencies = allDependencies(packageJson);
238
+ const imports = [];
239
+ const envNames = [];
240
+ const hosts = [];
241
+ const configFiles = [];
242
+
243
+ for (const file of files) {
244
+ const name = basename(file);
245
+ const ext = extname(file);
246
+ if (configFileNames.has(name)) configFiles.push(relative(options.root, file));
247
+ if (name.startsWith('.env')) envNames.push(...envNamesFromText(await readFile(file, 'utf8')));
248
+ if (sourceExtensions.has(ext) || configFileNames.has(name) || name.match(/README|\.md$/i)) {
249
+ const text = await readFile(file, 'utf8').catch(() => '');
250
+ imports.push(...importsFromText(text));
251
+ hosts.push(...urlHostsFromText(text));
252
+ }
253
+ }
254
+
255
+ const services = matchServices({
256
+ dependencies,
257
+ imports,
258
+ envNames: [...new Set(envNames)],
259
+ hosts: [...new Set(hosts)],
260
+ configFiles: configFiles.map((file) => basename(file)),
261
+ database,
262
+ });
263
+
264
+ const graph = {
265
+ generatedAt: new Date().toISOString(),
266
+ project: {
267
+ name: projectName,
268
+ root: options.root,
269
+ framework: detectFramework(options.root, packageJson, files),
270
+ },
271
+ services,
272
+ edges: buildEdges(projectName, services),
273
+ summary: {
274
+ serviceCount: services.length,
275
+ highConfidenceCount: services.filter((service) => service.confidence === 'high').length,
276
+ scannedFileCount: files.length,
277
+ },
278
+ };
279
+
280
+ await readdir(dirname(options.output)).catch(async () => {
281
+ await import('node:fs/promises').then((fs) => fs.mkdir(dirname(options.output), { recursive: true }));
282
+ });
283
+ await writeFile(options.output, `${JSON.stringify(graph, null, 2)}\n`);
284
+ console.log(`Wrote ${services.length} services to ${options.output}`);
285
+ }
286
+
287
+ main().catch((error) => {
288
+ console.error(error);
289
+ process.exit(1);
290
+ });