servicemap 0.1.0 → 0.1.5

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 CHANGED
@@ -6,12 +6,43 @@ The skill scans a software project for external services, resolves service logos
6
6
 
7
7
  ## Terminal Use
8
8
 
9
- The npm package name `servicemap` is available, so users can run the clean command:
9
+ The npm package name `servicemap` is available, so users can run the clean command and choose where to install it:
10
10
 
11
11
  ```bash
12
12
  npx servicemap
13
13
  ```
14
14
 
15
+ That opens the interactive install survey.
16
+ It lets users choose compatible AI coding agents and model environments.
17
+
18
+ To install the bundled AI skill so `/servicemap` appears in compatible coding agents:
19
+
20
+ ```bash
21
+ npx servicemap install-skill
22
+ ```
23
+
24
+ To use an interactive Caveman-style terminal survey:
25
+
26
+ ```bash
27
+ npx servicemap install-skill --survey
28
+ ```
29
+
30
+ That installs to the Codex-style user skill folder, matching the way Caveman installs a local skill folder.
31
+ To install for several agents on the same computer:
32
+
33
+ ```bash
34
+ npx servicemap install-skill --agent all
35
+ ```
36
+
37
+ The Caveman-style command shape also works:
38
+
39
+ ```bash
40
+ npx servicemap skills install --agent claude
41
+ ```
42
+
43
+ Agents with known local skill folders get the real `servicemap` skill installed.
44
+ Other selected AI tools get setup notes under `~/.servicemap/model-guides`.
45
+
15
46
  After a global install, the command is:
16
47
 
17
48
  ```bash
@@ -27,10 +58,22 @@ npm install -g servicemap
27
58
  CLI options:
28
59
 
29
60
  ```bash
61
+ servicemap
62
+ servicemap generate --root <project-path>
30
63
  servicemap --root <project-path>
64
+ servicemap generate --root <project-path> --allow-emails founder@example.com,ops@example.com
65
+ servicemap generate --root <project-path> --allow-email founder@example.com --allow-email ops@example.com
31
66
  servicemap --mode page
32
67
  servicemap --mode desktop
33
68
  servicemap --dry-run
69
+ servicemap install-skill
70
+ servicemap install-skill --survey
71
+ servicemap install-skill --agent codex
72
+ servicemap install-skill --agent claude
73
+ servicemap install-skill --agent grok
74
+ servicemap install-skill --agent all
75
+ servicemap install-skill --targets codex,gemini,cursor
76
+ servicemap skills install --agent codex
34
77
  ```
35
78
 
36
79
  ## Contents
@@ -45,7 +88,11 @@ servicemap --dry-run
45
88
  ## Validation
46
89
 
47
90
  ```bash
48
- node bin/servicemap.mjs --root demo-project --dry-run
91
+ node bin/servicemap.mjs generate --root demo-project --allow-emails founder@example.com,ops@example.com --dry-run
49
92
  node bin/servicemap.mjs --root no-website-demo --dry-run
93
+ node bin/servicemap.mjs --dry-run
94
+ node bin/servicemap.mjs install-skill --agent all --dry-run
95
+ node bin/servicemap.mjs install-skill --targets codex,gemini,cursor --dry-run
96
+ node bin/servicemap.mjs skills install --agent claude --dry-run
50
97
  python3 /Users/dougie/.codex/skills/.system/skill-creator/scripts/quick_validate.py servicemap
51
98
  ```
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { readFile } from 'node:fs/promises';
4
- import { dirname, resolve } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import readline from 'node:readline';
5
8
  import { fileURLToPath } from 'node:url';
6
9
 
7
10
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -9,18 +12,119 @@ const scanScript = resolve(packageRoot, 'servicemap/scripts/scan-project.mjs');
9
12
  const pageScript = resolve(packageRoot, 'servicemap/scripts/generate-page.mjs');
10
13
  const desktopScript = resolve(packageRoot, 'servicemap/scripts/generate-desktop-app.mjs');
11
14
 
15
+ const targetCatalog = [
16
+ { id: 'codex', label: 'OpenAI (Codex, ChatGPT, GPT models)', kind: 'skill', root: () => resolve(homedir(), '.agents/skills'), recommended: true },
17
+ { id: 'claude', label: 'Anthropic (Claude Code, Claude models)', kind: 'skill', root: () => resolve(homedir(), '.claude/skills'), recommended: true },
18
+ { id: 'grok', label: 'xAI (Grok)', kind: 'skill', root: () => resolve(homedir(), '.grok/skills'), recommended: true },
19
+ { id: 'gemini', label: 'Google (Gemini, Vertex AI)', kind: 'guide' },
20
+ { id: 'cursor', label: 'Cursor', kind: 'guide' },
21
+ { id: 'windsurf', label: 'Windsurf', kind: 'guide' },
22
+ { id: 'copilot', label: 'GitHub Copilot', kind: 'guide' },
23
+ { id: 'vscode-chat', label: 'VS Code Chat', kind: 'guide' },
24
+ { id: 'continue', label: 'Continue.dev', kind: 'guide' },
25
+ { id: 'aider', label: 'Aider', kind: 'guide' },
26
+ { id: 'cline', label: 'Cline', kind: 'guide' },
27
+ { id: 'roo-code', label: 'Roo Code', kind: 'guide' },
28
+ { id: 'zed-ai', label: 'Zed AI', kind: 'guide' },
29
+ { id: 'replit-agent', label: 'Replit Agent', kind: 'guide' },
30
+ { id: 'lovable', label: 'Lovable', kind: 'guide' },
31
+ { id: 'bolt', label: 'Bolt', kind: 'guide' },
32
+ { id: 'v0', label: 'v0', kind: 'guide' },
33
+ { id: 'perplexity', label: 'Perplexity', kind: 'guide' },
34
+ { id: 'mistral', label: 'Mistral', kind: 'guide' },
35
+ { id: 'deepseek', label: 'DeepSeek', kind: 'guide' },
36
+ { id: 'qwen', label: 'Qwen', kind: 'guide' },
37
+ { id: 'meta', label: 'Meta (Llama)', kind: 'guide' },
38
+ { id: 'local-models', label: 'Local models (Ollama, LM Studio)', kind: 'guide' },
39
+ { id: 'openrouter', label: 'OpenRouter', kind: 'guide' },
40
+ { id: 'bedrock', label: 'Amazon Bedrock', kind: 'guide' },
41
+ { id: 'azure-openai', label: 'Azure OpenAI', kind: 'guide' },
42
+ { id: 'poe', label: 'Poe', kind: 'guide' },
43
+ { id: 'you-com', label: 'You.com', kind: 'guide' },
44
+ { id: 'phind', label: 'Phind', kind: 'guide' },
45
+ { id: 'sourcegraph-cody', label: 'Sourcegraph Cody', kind: 'guide' },
46
+ { id: 'tabnine', label: 'Tabnine', kind: 'guide' },
47
+ { id: 'codeium', label: 'Codeium', kind: 'guide' },
48
+ { id: 'jetbrains-ai', label: 'JetBrains AI', kind: 'guide' },
49
+ { id: 'custom', label: 'Other custom model or agent', kind: 'guide' },
50
+ ];
51
+
52
+ const targetAliases = new Map([
53
+ ['agents', 'codex'],
54
+ ['chatgpt', 'codex'],
55
+ ['openai', 'codex'],
56
+ ['openai-api', 'codex'],
57
+ ['gpt-6-astra', 'codex'],
58
+ ['gpt-6-sol', 'codex'],
59
+ ['gpt-6-luna', 'codex'],
60
+ ['gpt-5.6-sol', 'codex'],
61
+ ['gpt-5.6-terra', 'codex'],
62
+ ['gpt-5.6-luna', 'codex'],
63
+ ['anthropic', 'claude'],
64
+ ['anthropic-api', 'claude'],
65
+ ['claude-opus', 'claude'],
66
+ ['claude-sonnet', 'claude'],
67
+ ['claude-haiku', 'claude'],
68
+ ['google', 'gemini'],
69
+ ['gemini-pro', 'gemini'],
70
+ ['gemini-flash', 'gemini'],
71
+ ['gemini-api', 'gemini'],
72
+ ['vertex-ai', 'gemini'],
73
+ ['llama', 'meta'],
74
+ ['ollama', 'local-models'],
75
+ ['lm-studio', 'local-models'],
76
+ ]);
77
+
12
78
  function parseArgs() {
13
79
  const args = process.argv.slice(2);
14
80
  const options = {
81
+ command: 'generate',
15
82
  root: process.cwd(),
16
83
  mode: 'auto',
17
84
  dryRun: false,
85
+ agent: 'codex',
86
+ targets: null,
87
+ skillsDir: null,
88
+ allowedEmails: [],
89
+ invokedWithoutArgs: args.length === 0,
90
+ explicitCommand: false,
91
+ generationRequested: false,
18
92
  };
19
93
 
94
+ if (args[0] === 'skills' && args[1] === 'install') {
95
+ options.command = 'install-skill';
96
+ args.splice(0, 2);
97
+ }
98
+
99
+ if (args[0] && !args[0].startsWith('-')) {
100
+ options.command = args.shift();
101
+ options.explicitCommand = true;
102
+ }
103
+
20
104
  for (let index = 0; index < args.length; index += 1) {
21
105
  const arg = args[index];
22
- if (arg === '--root') options.root = args[++index] ?? options.root;
23
- if (arg === '--mode') options.mode = args[++index] ?? options.mode;
106
+ if (arg === '--root') {
107
+ options.root = args[++index] ?? options.root;
108
+ options.generationRequested = true;
109
+ }
110
+ if (arg === '--mode') {
111
+ options.mode = args[++index] ?? options.mode;
112
+ options.generationRequested = true;
113
+ }
114
+ if (arg === '--agent') options.agent = args[++index] ?? options.agent;
115
+ if (arg === '--targets') options.targets = (args[++index] ?? '').split(',').map((target) => target.trim()).filter(Boolean);
116
+ if (arg === '--allow-email') {
117
+ options.allowedEmails.push(args[++index] ?? '');
118
+ options.generationRequested = true;
119
+ }
120
+ if (arg === '--allow-emails') {
121
+ options.allowedEmails.push(...(args[++index] ?? '').split(','));
122
+ options.generationRequested = true;
123
+ }
124
+ if (arg === '--skills-dir') options.skillsDir = resolve(args[++index] ?? '.');
125
+ if (arg === '--user') options.user = true;
126
+ if (arg === '--survey') options.survey = true;
127
+ if (arg === '--yes' || arg === '-y') options.yes = true;
24
128
  if (arg === '--dry-run') options.dryRun = true;
25
129
  if (arg === '--help' || arg === '-h') options.help = true;
26
130
  }
@@ -33,11 +137,21 @@ function help() {
33
137
  console.log(`Servicemap
34
138
 
35
139
  Usage:
36
- servicemap [--root <project>] [--mode auto|page|desktop] [--dry-run]
140
+ servicemap
141
+ servicemap generate [--root <project>] [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
142
+ servicemap --root <project> [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
143
+ servicemap install-skill [--survey] [--agent codex|claude|grok|all] [--targets <ids>]
144
+ servicemap skills install [--survey] [--agent codex|claude|grok|all]
37
145
 
38
146
  Examples:
39
147
  npx servicemap
148
+ npx servicemap generate --root ../my-app --allow-emails founder@example.com,ops@example.com
40
149
  npx servicemap -- --root ../my-app
150
+ npx servicemap install-skill
151
+ npx servicemap install-skill --survey
152
+ npx servicemap install-skill --agent all
153
+ npx servicemap install-skill --targets codex,claude,cursor
154
+ npx servicemap skills install --agent claude
41
155
  servicemap --mode desktop
42
156
  `);
43
157
  }
@@ -62,6 +176,159 @@ async function readScan(root) {
62
176
  return JSON.parse(await readFile(scanPath, 'utf8'));
63
177
  }
64
178
 
179
+ function targetsByIds(ids) {
180
+ const byId = new Map(targetCatalog.map((target) => [target.id, target]));
181
+ return ids.map((id) => {
182
+ const target = byId.get(targetAliases.get(id) ?? id);
183
+ if (!target) {
184
+ throw new Error(`Unknown target "${id}". Use --survey to choose from the list.`);
185
+ }
186
+ return target;
187
+ });
188
+ }
189
+
190
+ function targetsForOptions(options) {
191
+ if (options.targets?.length) return targetsByIds(options.targets);
192
+ if (options.agent === 'all') return targetCatalog.filter((target) => target.kind === 'skill');
193
+ return targetsByIds([options.agent]);
194
+ }
195
+
196
+ function skillRootsForTargets(options, targets) {
197
+ const skillTargets = targets.filter((target) => target.kind === 'skill');
198
+ if (options.skillsDir && skillTargets.length) return [options.skillsDir];
199
+ return [...new Set(skillTargets.map((target) => target.root()))];
200
+ }
201
+
202
+ function guideTargets(targets) {
203
+ return targets.filter((target) => target.kind === 'guide');
204
+ }
205
+
206
+ function renderSurvey(selected, cursor) {
207
+ console.clear();
208
+ console.log('Where should Servicemap be available?');
209
+ console.log('Use ↑/↓, Space to select, Enter to install.\n');
210
+
211
+ targetCatalog.forEach((target, index) => {
212
+ const pointer = index === cursor ? '>' : ' ';
213
+ const checked = selected.has(target.id) ? 'x' : ' ';
214
+ const detail = target.kind === 'skill' ? 'skill folder' : 'setup notes';
215
+ console.log(`${pointer} [${checked}] ${target.label} (${detail})`);
216
+ });
217
+ }
218
+
219
+ function runTargetSurvey() {
220
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
221
+ return Promise.resolve(targetCatalog.filter((target) => target.recommended));
222
+ }
223
+
224
+ const selected = new Set(targetCatalog.filter((target) => target.recommended).map((target) => target.id));
225
+ let cursor = 0;
226
+
227
+ return new Promise((resolvePromise) => {
228
+ readline.emitKeypressEvents(process.stdin);
229
+ process.stdin.setRawMode(true);
230
+ renderSurvey(selected, cursor);
231
+
232
+ const onKeypress = (_str, key) => {
233
+ if (key.name === 'up') cursor = (cursor - 1 + targetCatalog.length) % targetCatalog.length;
234
+ if (key.name === 'down') cursor = (cursor + 1) % targetCatalog.length;
235
+ if (key.name === 'space') {
236
+ const id = targetCatalog[cursor].id;
237
+ if (selected.has(id)) selected.delete(id);
238
+ else selected.add(id);
239
+ }
240
+ if (key.name === 'return') {
241
+ process.stdin.setRawMode(false);
242
+ process.stdin.off('keypress', onKeypress);
243
+ console.log('');
244
+ resolvePromise(targetCatalog.filter((target) => selected.has(target.id)));
245
+ return;
246
+ }
247
+ if (key.ctrl && key.name === 'c') {
248
+ process.stdin.setRawMode(false);
249
+ process.stdin.off('keypress', onKeypress);
250
+ process.exit(130);
251
+ }
252
+
253
+ renderSurvey(selected, cursor);
254
+ };
255
+
256
+ process.stdin.on('keypress', onKeypress);
257
+ });
258
+ }
259
+
260
+ function guideText(target) {
261
+ return `# Servicemap for ${target.label}
262
+
263
+ These setup notes help ${target.label} use Servicemap when you want it to create a service map for a software project.
264
+
265
+ Invoke: /servicemap
266
+
267
+ Goal:
268
+ Scan the current project for third-party services, resolve service logos from the bundled Servicemap database, and create an /internalservicemap page when a website is present.
269
+ If no website can be safely found, create a desktop app fallback named servicemap-desktop.
270
+
271
+ Recommended local command:
272
+ \`\`\`bash
273
+ npx servicemap --root <project-path>
274
+ \`\`\`
275
+
276
+ If your agent supports local skills, install the real skill folder with:
277
+ \`\`\`bash
278
+ npx servicemap install-skill --agent all
279
+ \`\`\`
280
+ `;
281
+ }
282
+
283
+ async function writeGuidePacks(targets, options) {
284
+ const guideRoot = resolve(homedir(), '.servicemap/model-guides');
285
+ for (const target of guideTargets(targets)) {
286
+ const destination = join(guideRoot, target.id);
287
+ if (options.dryRun) {
288
+ console.log(`Would write Servicemap guide for ${target.label} to ${destination}`);
289
+ continue;
290
+ }
291
+ await mkdir(destination, { recursive: true });
292
+ await cp(resolve(packageRoot, 'servicemap/SKILL.md'), join(destination, 'SKILL.md'));
293
+ await mkdir(join(destination, 'servicemap'), { recursive: true });
294
+ await cp(resolve(packageRoot, 'servicemap'), join(destination, 'servicemap'), { recursive: true });
295
+ await writeFile(join(destination, 'README.md'), guideText(target));
296
+ console.log(`Wrote Servicemap guide for ${target.label} to ${destination}`);
297
+ }
298
+ }
299
+
300
+ async function installSkill(options) {
301
+ const source = resolve(packageRoot, 'servicemap');
302
+ const selectedTargets = options.survey && !options.yes
303
+ ? await runTargetSurvey()
304
+ : targetsForOptions(options);
305
+ const roots = skillRootsForTargets(options, selectedTargets);
306
+
307
+ if (!existsSync(source)) {
308
+ throw new Error(`Bundled skill folder not found: ${source}`);
309
+ }
310
+
311
+ if (options.dryRun) {
312
+ for (const root of roots) {
313
+ console.log(`Would install Servicemap skill to ${join(root, 'servicemap')}`);
314
+ }
315
+ await writeGuidePacks(selectedTargets, options);
316
+ return;
317
+ }
318
+
319
+ for (const root of roots) {
320
+ const destination = join(root, 'servicemap');
321
+ await mkdir(root, { recursive: true });
322
+ await rm(destination, { recursive: true, force: true });
323
+ await cp(source, destination, { recursive: true });
324
+ console.log(`Installed Servicemap skill to ${destination}`);
325
+ }
326
+
327
+ await writeGuidePacks(selectedTargets, options);
328
+
329
+ console.log('Restart your AI app if /servicemap does not appear immediately.');
330
+ }
331
+
65
332
  async function main() {
66
333
  const options = parseArgs();
67
334
  if (options.help) {
@@ -69,11 +336,29 @@ async function main() {
69
336
  return;
70
337
  }
71
338
 
339
+ if (options.command === 'generate' && !options.explicitCommand && !options.generationRequested) {
340
+ options.command = 'install-skill';
341
+ options.survey = true;
342
+ }
343
+
344
+ if (options.command === 'install-skill' || options.command === 'install') {
345
+ await installSkill(options);
346
+ return;
347
+ }
348
+
349
+ if (options.command !== 'generate') {
350
+ throw new Error(`Unknown command "${options.command}". Use generate, install-skill, or skills install.`);
351
+ }
352
+
72
353
  if (!['auto', 'page', 'desktop'].includes(options.mode)) {
73
354
  throw new Error(`Unknown mode "${options.mode}". Use auto, page, or desktop.`);
74
355
  }
75
356
 
76
- await run(process.execPath, [scanScript, '--root', options.root]);
357
+ const scanArgs = [scanScript, '--root', options.root];
358
+ for (const email of options.allowedEmails) {
359
+ scanArgs.push('--allow-email', email);
360
+ }
361
+ await run(process.execPath, scanArgs);
77
362
  const scan = await readScan(options.root);
78
363
  const selectedMode = options.mode === 'auto'
79
364
  ? scan.project?.framework === 'static' ? 'desktop' : 'page'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "README.md"
13
13
  ],
14
14
  "scripts": {
15
- "test": "node bin/servicemap.mjs --root demo-project --dry-run && node bin/servicemap.mjs --root no-website-demo --dry-run",
15
+ "test": "node bin/servicemap.mjs generate --root demo-project --allow-emails founder@example.com,ops@example.com --dry-run && node bin/servicemap.mjs --root no-website-demo --dry-run && node bin/servicemap.mjs --dry-run && HOME=\"$(mktemp -d)\" node bin/servicemap.mjs && node bin/servicemap.mjs install-skill --agent all --dry-run && node bin/servicemap.mjs install-skill --targets codex,gemini,cursor --dry-run && node bin/servicemap.mjs install-skill --targets chatgpt,gpt-6-astra,gemini-flash --dry-run && node bin/servicemap.mjs skills install --agent claude --dry-run",
16
16
  "validate:skill": "python3 /Users/dougie/.codex/skills/.system/skill-creator/scripts/quick_validate.py servicemap"
17
17
  },
18
18
  "keywords": [
@@ -14,13 +14,15 @@ If no website can be found, create a desktop app fallback at `servicemap-desktop
14
14
  ## Workflow
15
15
 
16
16
  1. Inspect the project layout and current web framework.
17
- 2. Run `scripts/scan-project.mjs --root <project-root>` to create `.servicemap/internal-servicemap.json`.
18
- 3. Review the scan output for obvious false positives or missing high-confidence services.
19
- 4. If the scan detects a web framework or routeable website, run `scripts/generate-page.mjs --root <project-root>` to create the framework-specific `/internalservicemap` route.
20
- 5. If the scan reports `framework: static` or no website route can be safely attached, run `scripts/generate-desktop-app.mjs --root <project-root>` to create `servicemap-desktop`.
21
- 6. Adapt the generated surface to the project's existing design system only when the route is clear and the edits are low-risk.
22
- 7. Run the project's relevant build, lint, and focused tests.
23
- 8. Report the page or desktop app path, service count, validation run, and any services marked low or medium confidence.
17
+ 2. If the user gives a list of emails that may access the generated web page, pass them to the scan command with `--allow-email <email>` for each address or `--allow-emails <comma-separated-list>`.
18
+ 3. Run `scripts/scan-project.mjs --root <project-root>` to create `.servicemap/internal-servicemap.json`.
19
+ Include the access flags from the previous step when relevant.
20
+ 4. Review the scan output for obvious false positives or missing high-confidence services.
21
+ 5. If the scan detects a web framework or routeable website, run `scripts/generate-page.mjs --root <project-root>` to create the framework-specific `/internalservicemap` route.
22
+ 6. If the scan reports `framework: static` or no website route can be safely attached, run `scripts/generate-desktop-app.mjs --root <project-root>` to create `servicemap-desktop`.
23
+ 7. Adapt the generated surface to the project's existing design system only when the route is clear and the edits are low-risk.
24
+ 8. Run the project's relevant build, lint, and focused tests.
25
+ 9. Report the page or desktop app path, service count, validation run, any services marked low or medium confidence, and whether requested email access was enforced.
24
26
 
25
27
  ## Safety Rules
26
28
 
@@ -30,6 +32,9 @@ The scan output may include env var names such as `STRIPE_SECRET_KEY`, but not t
30
32
 
31
33
  Do not add authentication, deployment, analytics, or external writes unless the user explicitly asks.
32
34
  If the repository already has route protection conventions for internal pages, follow them.
35
+ When email access is requested, enforce it only through the app's existing server-side authentication.
36
+ The built-in generator can enforce email allowlists for Next.js App Router projects using Clerk by checking `currentUser()` against the configured emails.
37
+ For unsupported frameworks or static output, report that the allowlist is recorded but not enforced.
33
38
  If no website route can be safely attached, create the desktop app fallback instead of forcing website files into an unrelated project.
34
39
 
35
40
  ## Supporting References
@@ -70,6 +70,7 @@ function pageCss() {
70
70
  .service-name span, .evidence { color: #64748b; font-size: 12px; }
71
71
  .badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: #edf2ff; color: #334155; font-size: 12px; margin-bottom: 10px; }
72
72
  .evidence { margin-top: 8px; }
73
+ .access-warning { margin: 0 0 18px; padding: 12px 14px; border: 1px solid #f5c542; border-radius: 8px; background: #fff7d6; color: #614700; font-size: 14px; line-height: 1.45; }
73
74
  .empty { background: #fff; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 24px; }
74
75
  @media (max-width: 720px) { .servicemap-header { display: block; } .summary { grid-template-columns: 1fr; } }
75
76
  `;
@@ -77,6 +78,11 @@ function pageCss() {
77
78
 
78
79
  function htmlBody(graph) {
79
80
  const services = graph.services ?? [];
81
+ const access = graph.access ?? {};
82
+ const hasAccessRequest = access.requested && access.allowedEmails?.length;
83
+ const accessNotice = hasAccessRequest && access.enforced !== true
84
+ ? '<div class="access-warning">Email access was requested, but this project does not expose a supported server-side auth guard for generated pages. Put this route behind your app auth before sharing it.</div>'
85
+ : '';
80
86
  const serviceCards = services.length
81
87
  ? services.map((service) => {
82
88
  const evidence = service.evidence?.[0];
@@ -111,6 +117,7 @@ function htmlBody(graph) {
111
117
  <div class="metric"><strong>${graph.summary?.highConfidenceCount ?? 0}</strong><span>high confidence</span></div>
112
118
  <div class="metric"><strong>${escapeHtml(graph.project?.framework ?? 'unknown')}</strong><span>framework</span></div>
113
119
  </section>
120
+ ${accessNotice}
114
121
  <section class="service-grid">
115
122
  ${serviceCards}
116
123
  </section>
@@ -136,6 +143,36 @@ ${htmlBody(graph)}
136
143
 
137
144
  function tsxPage(graph) {
138
145
  const html = htmlBody(graph);
146
+ const allowedEmails = graph.access?.allowedEmails ?? [];
147
+ const usesClerk = (graph.services ?? []).some((service) => service.key === 'clerk');
148
+ const canEnforce = allowedEmails.length > 0 && usesClerk && graph.project?.framework === 'next-app-router';
149
+ if (canEnforce) {
150
+ graph.access.enforced = true;
151
+ const guardedHtml = htmlBody(graph);
152
+ return `import { currentUser } from '@clerk/nextjs/server';
153
+ import { notFound } from 'next/navigation';
154
+
155
+ const css = ${JSON.stringify(pageCss())};
156
+ const html = ${JSON.stringify(guardedHtml)};
157
+ const allowedEmails = ${JSON.stringify(allowedEmails)};
158
+
159
+ export default async function InternalServiceMapPage() {
160
+ const user = await currentUser();
161
+ const userEmails = user?.emailAddresses?.map((email) => email.emailAddress.toLowerCase()) ?? [];
162
+ const canView = userEmails.some((email) => allowedEmails.includes(email));
163
+
164
+ if (!canView) notFound();
165
+
166
+ return (
167
+ <>
168
+ <style dangerouslySetInnerHTML={{ __html: css }} />
169
+ <div dangerouslySetInnerHTML={{ __html: html }} />
170
+ </>
171
+ );
172
+ }
173
+ `;
174
+ }
175
+
139
176
  return `const css = ${JSON.stringify(pageCss())};
140
177
  const html = ${JSON.stringify(html)};
141
178
 
@@ -65,16 +65,26 @@ function service(name, key, category, packages, envPrefixes, domains) {
65
65
 
66
66
  function parseArgs() {
67
67
  const args = process.argv.slice(2);
68
- const options = { root: process.cwd(), output: defaultOutput };
68
+ const options = { root: process.cwd(), output: defaultOutput, allowedEmails: [] };
69
69
  for (let index = 0; index < args.length; index += 1) {
70
70
  if (args[index] === '--root') options.root = args[index + 1] ?? options.root;
71
71
  if (args[index] === '--output') options.output = args[index + 1] ?? options.output;
72
+ if (args[index] === '--allow-email') options.allowedEmails.push(args[index + 1] ?? '');
73
+ if (args[index] === '--allow-emails') options.allowedEmails.push(...(args[index + 1] ?? '').split(','));
72
74
  }
73
75
  options.root = resolve(options.root);
74
76
  options.output = resolve(options.root, options.output);
77
+ options.allowedEmails = normalizeEmails(options.allowedEmails);
75
78
  return options;
76
79
  }
77
80
 
81
+ function normalizeEmails(values) {
82
+ return [...new Set(values
83
+ .map((value) => value.trim().toLowerCase())
84
+ .filter(Boolean)
85
+ .filter((value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)))];
86
+ }
87
+
78
88
  async function readJson(path) {
79
89
  try {
80
90
  return JSON.parse(await readFile(path, 'utf8'));
@@ -268,6 +278,10 @@ async function main() {
268
278
  root: options.root,
269
279
  framework: detectFramework(options.root, packageJson, files),
270
280
  },
281
+ access: {
282
+ allowedEmails: options.allowedEmails,
283
+ requested: options.allowedEmails.length > 0,
284
+ },
271
285
  services,
272
286
  edges: buildEdges(projectName, services),
273
287
  summary: {