servicemap 0.1.21 → 0.1.24

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.
@@ -2,6 +2,7 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { cp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
5
+ import net from 'node:net';
5
6
  import { dirname, join, resolve } from 'node:path';
6
7
  import { homedir } from 'node:os';
7
8
  import readline from 'node:readline';
@@ -166,6 +167,7 @@ function parseArgs() {
166
167
  skillsDir: null,
167
168
  installMethod: null,
168
169
  allowedEmails: [],
170
+ open: true,
169
171
  invokedWithoutArgs: args.length === 0,
170
172
  explicitCommand: false,
171
173
  generationRequested: false,
@@ -207,6 +209,7 @@ function parseArgs() {
207
209
  if (arg === '--survey') options.survey = true;
208
210
  if (arg === '--yes' || arg === '-y') options.yes = true;
209
211
  if (arg === '--dry-run') options.dryRun = true;
212
+ if (arg === '--no-open') options.open = false;
210
213
  if (arg === '--help' || arg === '-h') options.help = true;
211
214
  }
212
215
 
@@ -219,8 +222,8 @@ function help() {
219
222
 
220
223
  Usage:
221
224
  servicemap
222
- servicemap generate [--root <project>] [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
223
- servicemap --root <project> [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
225
+ servicemap generate [--root <project>] [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run] [--no-open]
226
+ servicemap --root <project> [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run] [--no-open]
224
227
  servicemap install-skill [--survey] [--agent codex|claude|grok|all] [--targets <ids>] [--install-method copy|symlink]
225
228
  servicemap skills install [--survey] [--agent codex|claude|grok|all]
226
229
 
@@ -252,6 +255,96 @@ function run(command, args, options = {}) {
252
255
  });
253
256
  }
254
257
 
258
+ function openUrl(url) {
259
+ const command = process.platform === 'darwin'
260
+ ? 'open'
261
+ : process.platform === 'win32'
262
+ ? 'cmd'
263
+ : 'xdg-open';
264
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
265
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
266
+ child.unref();
267
+ }
268
+
269
+ function findOpenPort() {
270
+ return new Promise((resolvePromise, reject) => {
271
+ const server = net.createServer();
272
+ server.on('error', reject);
273
+ server.listen(0, '127.0.0.1', () => {
274
+ const address = server.address();
275
+ const port = typeof address === 'object' && address ? address.port : 5173;
276
+ server.close(() => resolvePromise(port));
277
+ });
278
+ });
279
+ }
280
+
281
+ function waitForPort(port, timeoutMs = 10000) {
282
+ const startedAt = Date.now();
283
+ return new Promise((resolvePromise, reject) => {
284
+ const tryConnect = () => {
285
+ const socket = net.connect({ host: '127.0.0.1', port });
286
+ socket.on('connect', () => {
287
+ socket.destroy();
288
+ resolvePromise();
289
+ });
290
+ socket.on('error', () => {
291
+ socket.destroy();
292
+ if (Date.now() - startedAt > timeoutMs) {
293
+ reject(new Error(`Timed out waiting for desktop preview on port ${port}`));
294
+ return;
295
+ }
296
+ setTimeout(tryConnect, 150);
297
+ });
298
+ };
299
+ tryConnect();
300
+ });
301
+ }
302
+
303
+ async function openDesktopPreview(root) {
304
+ const appRoot = resolve(root, 'servicemap-desktop');
305
+ if (!existsSync(join(appRoot, 'node_modules'))) {
306
+ console.log('Installing desktop preview dependencies...');
307
+ await run('npm', ['install'], { cwd: appRoot });
308
+ }
309
+
310
+ const port = await findOpenPort();
311
+ const child = spawn('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], {
312
+ cwd: appRoot,
313
+ stdio: 'ignore',
314
+ detached: true,
315
+ });
316
+ child.unref();
317
+
318
+ await waitForPort(port);
319
+ const url = `http://127.0.0.1:${port}/`;
320
+ openUrl(url);
321
+ console.log(`Opened desktop servicemap preview at ${url}`);
322
+ }
323
+
324
+ async function openGeneratedPage(root, framework) {
325
+ if (framework === 'static') {
326
+ const filePath = resolve(root, 'public/internalservicemap.html');
327
+ openUrl(filePath);
328
+ console.log(`Opened generated servicemap page at ${filePath}`);
329
+ return;
330
+ }
331
+
332
+ const packageJson = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8').catch(() => '{}'));
333
+ const dependencies = {
334
+ ...(packageJson.dependencies ?? {}),
335
+ ...(packageJson.devDependencies ?? {}),
336
+ };
337
+ const port = dependencies.next ? 3000 : dependencies.vite ? 5173 : null;
338
+ if (!port) {
339
+ console.log('Generated /internalservicemap. Start the project dev server to view it.');
340
+ return;
341
+ }
342
+
343
+ const url = `http://127.0.0.1:${port}/internalservicemap`;
344
+ openUrl(url);
345
+ console.log(`Opened generated servicemap page at ${url}`);
346
+ }
347
+
255
348
  async function readScan(root) {
256
349
  const scanPath = resolve(root, '.servicemap/internal-servicemap.json');
257
350
  return JSON.parse(await readFile(scanPath, 'utf8'));
@@ -600,8 +693,10 @@ async function main() {
600
693
 
601
694
  if (selectedMode === 'desktop') {
602
695
  await run(process.execPath, [desktopScript, '--root', options.root]);
696
+ if (options.open) await openDesktopPreview(options.root);
603
697
  } else {
604
698
  await run(process.execPath, [pageScript, '--root', options.root]);
699
+ if (options.open) await openGeneratedPage(options.root, scan.project?.framework);
605
700
  }
606
701
  }
607
702
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.21",
3
+ "version": "0.1.24",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -89,7 +89,7 @@ function indexHtml() {
89
89
  function mainJsx() {
90
90
  return `import React from 'react';
91
91
  import { createRoot } from 'react-dom/client';
92
- import graph from '.servicemap-data.json';
92
+ import graph from './servicemap-data.json';
93
93
  import './styles.css';
94
94
 
95
95
  function strongestEvidence(service) {
@@ -97,42 +97,111 @@ function strongestEvidence(service) {
97
97
  return evidence ? \`\${evidence.type}: \${evidence.value}\` : 'No evidence captured';
98
98
  }
99
99
 
100
+ function categoryLabel(value) {
101
+ return String(value ?? 'service').replace(/-/g, ' ');
102
+ }
103
+
104
+ function categoryClass(value) {
105
+ return String(value ?? 'service').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
106
+ }
107
+
108
+ function layoutServices(services) {
109
+ const left = [];
110
+ const right = [];
111
+ services.forEach((service, index) => {
112
+ if (index % 2 === 0) left.push(service);
113
+ else right.push(service);
114
+ });
115
+
116
+ const place = (items, side) => items.map((service, index) => {
117
+ const count = Math.max(items.length, 1);
118
+ const y = 15 + ((index + 0.5) * 70) / count;
119
+ const x = side === 'left' ? 10 + (index % 2) * 13 : 71 + (index % 2) * 10;
120
+ return { service, x, y, side };
121
+ });
122
+
123
+ return [...place(left, 'left'), ...place(right, 'right')];
124
+ }
125
+
100
126
  function App() {
101
127
  const services = graph.services ?? [];
102
128
  const highConfidence = services.filter((service) => service.confidence === 'high').length;
129
+ const nodes = layoutServices(services);
103
130
 
104
131
  return (
105
- <main className="shell">
132
+ <main className="appShell">
106
133
  <header className="topbar">
107
- <div>
108
- <h1>Internal Servicemap</h1>
109
- <p>{graph.project?.name ?? 'This project'} uses these detected services.</p>
134
+ <div className="brand">
135
+ <div className="brandIcon" aria-hidden="true">⌘</div>
136
+ <div>
137
+ <h1>service-map</h1>
138
+ <p>{graph.project?.name ?? 'Internal'} command center</p>
139
+ </div>
140
+ </div>
141
+ <div className="toolbar" aria-label="Service map actions">
142
+ <span className="identity">{graph.project?.framework ?? 'static'}</span>
143
+ <button type="button">2D</button>
144
+ <button type="button" className="ghost">3D</button>
145
+ <button type="button" className="ghost">Import</button>
146
+ <button type="button" className="primary">+ Add service</button>
110
147
  </div>
111
- <span className="stamp">{new Date(graph.generatedAt).toLocaleString()}</span>
112
148
  </header>
113
149
 
114
- <section className="metrics" aria-label="Service map summary">
115
- <div><strong>{services.length}</strong><span>services</span></div>
116
- <div><strong>{highConfidence}</strong><span>high confidence</span></div>
117
- <div><strong>{graph.project?.framework ?? 'unknown'}</strong><span>source</span></div>
118
- </section>
150
+ <section className="mapCanvas" aria-label="Detected service map">
151
+ <div className="sectionLabel">
152
+ <span>{(graph.project?.name ?? 'project').toUpperCase()} INFRASTRUCTURE</span>
153
+ <small>{services.length} services · {highConfidence} high confidence</small>
154
+ </div>
119
155
 
120
- <section className="grid">
121
- {services.length ? services.map((service) => (
122
- <article className="card" key={service.id}>
123
- <div className="cardTop">
124
- <div className="logo" aria-hidden="true">
125
- {service.iconUrl ? <img src={service.iconUrl} alt="" /> : service.name.slice(0, 2).toUpperCase()}
126
- </div>
127
- <div className="nameBlock">
128
- <strong>{service.name}</strong>
129
- <span>{service.confidence} confidence</span>
130
- </div>
156
+ <svg className="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
157
+ {nodes.map((node) => {
158
+ const startX = node.side === 'left' ? node.x + 13 : node.x;
159
+ const endX = 50;
160
+ const midX = node.side === 'left' ? 38 : 62;
161
+ return (
162
+ <path
163
+ key={node.service.id}
164
+ d={\`M \${startX} \${node.y} H \${midX} V 50 H \${endX}\`}
165
+ className={node.service.confidence === 'high' ? 'edge edgeHigh' : 'edge'}
166
+ />
167
+ );
168
+ })}
169
+ </svg>
170
+
171
+ <div className="centerNode" aria-label="Project">
172
+ <div className="coreIcon" aria-hidden="true">⌬</div>
173
+ </div>
174
+
175
+ {nodes.map(({ service, x, y }) => (
176
+ <article
177
+ className={\`serviceNode \${service.confidence === 'high' ? 'isHigh' : ''}\`}
178
+ key={service.id}
179
+ style={{ left: \`\${x}%\`, top: \`\${y}%\` }}
180
+ title={strongestEvidence(service)}
181
+ >
182
+ <div className={\`logo logo-\${categoryClass(service.category)}\`} aria-hidden="true">
183
+ {service.iconUrl ? <img src={service.iconUrl} alt="" /> : service.name.slice(0, 2).toUpperCase()}
184
+ </div>
185
+ <div className="nameBlock">
186
+ <strong>{service.name}</strong>
187
+ <span>{categoryLabel(service.category)}</span>
131
188
  </div>
132
- <span className="category">{service.category}</span>
133
- <p>{strongestEvidence(service)}</p>
189
+ <span className={\`statusDot \${service.confidence === 'high' ? 'active' : 'pending'}\`} />
134
190
  </article>
135
- )) : <div className="empty">No external services were detected yet.</div>}
191
+ ))}
192
+
193
+ {!services.length && (
194
+ <div className="empty">
195
+ <strong>No external services detected</strong>
196
+ <span>Run the scanner again after adding integrations.</span>
197
+ </div>
198
+ )}
199
+
200
+ <div className="legend">
201
+ <span><b className="blue" /> Cloud</span>
202
+ <span><b className="green" /> Active path</span>
203
+ <span><b className="yellow" /> Lower confidence</span>
204
+ </div>
136
205
  </section>
137
206
  </main>
138
207
  );
@@ -144,8 +213,8 @@ createRoot(document.getElementById('root')).render(<App />);
144
213
 
145
214
  function stylesCss() {
146
215
  return `:root {
147
- color: #172033;
148
- background: #f7f8fb;
216
+ color: #eef7f2;
217
+ background: #020806;
149
218
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
150
219
  }
151
220
 
@@ -157,107 +226,244 @@ body {
157
226
  margin: 0;
158
227
  min-width: 320px;
159
228
  min-height: 100vh;
160
- background: #f7f8fb;
229
+ background: #020806;
161
230
  }
162
231
 
163
- .shell {
164
- max-width: 1180px;
165
- margin: 0 auto;
166
- padding: 40px 24px 56px;
232
+ button {
233
+ border: 0;
234
+ border-radius: 8px;
235
+ min-height: 42px;
236
+ padding: 0 16px;
237
+ color: #e8f5ee;
238
+ background: #101a16;
239
+ font: inherit;
240
+ font-weight: 700;
241
+ }
242
+
243
+ .appShell {
244
+ min-height: 100vh;
245
+ background: #020806;
167
246
  }
168
247
 
169
248
  .topbar {
249
+ height: 86px;
250
+ padding: 18px 28px;
170
251
  display: flex;
252
+ align-items: center;
171
253
  justify-content: space-between;
172
- align-items: flex-end;
173
- gap: 24px;
174
- margin-bottom: 24px;
254
+ gap: 20px;
255
+ background: #202622;
256
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
257
+ }
258
+
259
+ .brand {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: 14px;
263
+ min-width: 0;
264
+ }
265
+
266
+ .brandIcon {
267
+ width: 48px;
268
+ height: 48px;
269
+ border-radius: 12px;
270
+ display: grid;
271
+ place-items: center;
272
+ background: #1f704f;
273
+ border: 1px solid #38c987;
274
+ box-shadow: 0 0 24px rgba(56, 201, 135, 0.25);
275
+ color: #c9f8df;
276
+ font-size: 25px;
175
277
  }
176
278
 
177
279
  h1 {
178
- margin: 0 0 8px;
179
- font-size: 44px;
280
+ margin: 0 0 3px;
281
+ font-size: 22px;
180
282
  line-height: 1;
181
283
  letter-spacing: 0;
182
284
  }
183
285
 
184
286
  p {
185
287
  margin: 0;
186
- color: #5f6b7c;
187
- line-height: 1.55;
188
288
  }
189
289
 
190
- .stamp {
191
- color: #64748b;
290
+ .brand p,
291
+ .nameBlock span,
292
+ .sectionLabel,
293
+ .legend {
294
+ color: rgba(238, 247, 242, 0.54);
295
+ }
296
+
297
+ .toolbar {
298
+ display: flex;
299
+ align-items: center;
300
+ gap: 10px;
301
+ min-width: 0;
302
+ }
303
+
304
+ .identity {
305
+ color: #93c8ac;
192
306
  font-size: 13px;
307
+ font-weight: 800;
308
+ margin-right: 10px;
193
309
  white-space: nowrap;
194
310
  }
195
311
 
196
- .metrics {
197
- display: grid;
198
- grid-template-columns: repeat(3, minmax(0, 1fr));
199
- gap: 12px;
200
- margin: 22px 0;
312
+ .toolbar .primary {
313
+ background: #247b58;
314
+ box-shadow: inset 0 0 0 1px rgba(104, 255, 179, 0.25);
201
315
  }
202
316
 
203
- .metrics div,
204
- .card {
205
- background: #fff;
206
- border: 1px solid #e3e8ef;
207
- border-radius: 8px;
208
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
317
+ .toolbar .ghost {
318
+ color: rgba(238, 247, 242, 0.72);
319
+ }
320
+
321
+ .mapCanvas {
322
+ position: relative;
323
+ min-height: calc(100vh - 86px);
324
+ overflow: hidden;
325
+ background:
326
+ radial-gradient(circle at center, rgba(37, 128, 88, 0.16), transparent 34%),
327
+ radial-gradient(circle at 1px 1px, rgba(115, 255, 183, 0.16) 1px, transparent 1px),
328
+ #020806;
329
+ background-size: auto, 28px 28px, auto;
330
+ }
331
+
332
+ .sectionLabel {
333
+ position: absolute;
334
+ top: 28px;
335
+ left: 32px;
336
+ right: 32px;
337
+ display: flex;
338
+ justify-content: space-between;
339
+ gap: 18px;
340
+ font-size: 13px;
341
+ font-weight: 800;
342
+ letter-spacing: 0.16em;
343
+ text-transform: uppercase;
209
344
  }
210
345
 
211
- .metrics div {
212
- padding: 16px;
346
+ .sectionLabel small {
347
+ letter-spacing: 0.08em;
213
348
  }
214
349
 
215
- .metrics strong {
216
- display: block;
217
- font-size: 26px;
350
+ .edges {
351
+ position: absolute;
352
+ inset: 0;
353
+ width: 100%;
354
+ height: 100%;
355
+ pointer-events: none;
218
356
  }
219
357
 
220
- .metrics span {
221
- color: #64748b;
222
- font-size: 13px;
358
+ .edge {
359
+ fill: none;
360
+ stroke: rgba(121, 158, 140, 0.34);
361
+ stroke-width: 0.12;
223
362
  }
224
363
 
225
- .grid {
364
+ .edgeHigh {
365
+ stroke: rgba(115, 255, 183, 0.5);
366
+ }
367
+
368
+ .centerNode {
369
+ position: absolute;
370
+ left: 50%;
371
+ top: 50%;
372
+ width: 112px;
373
+ height: 112px;
374
+ transform: translate(-50%, -50%);
226
375
  display: grid;
227
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
228
- gap: 12px;
376
+ place-items: center;
377
+ border-radius: 26px;
378
+ background: rgba(15, 31, 24, 0.88);
379
+ border: 1px solid rgba(114, 255, 183, 0.28);
380
+ box-shadow: 0 0 0 9px rgba(56, 201, 135, 0.08), 0 0 45px rgba(56, 201, 135, 0.18);
229
381
  }
230
382
 
231
- .card {
232
- min-height: 150px;
233
- padding: 14px;
383
+ .coreIcon {
384
+ width: 78px;
385
+ height: 78px;
386
+ display: grid;
387
+ place-items: center;
388
+ border-radius: 20px;
389
+ background: #f7fbf7;
390
+ color: #101513;
391
+ font-size: 38px;
234
392
  }
235
393
 
236
- .cardTop {
237
- display: flex;
394
+ .serviceNode {
395
+ position: absolute;
396
+ width: clamp(190px, 16vw, 260px);
397
+ min-height: 70px;
398
+ transform: translateY(-50%);
399
+ display: grid;
400
+ grid-template-columns: 48px minmax(0, 1fr) 12px;
238
401
  align-items: center;
239
- gap: 10px;
240
- margin-bottom: 10px;
402
+ gap: 12px;
403
+ padding: 12px;
404
+ border-radius: 14px;
405
+ background: linear-gradient(180deg, rgba(22, 33, 27, 0.98), rgba(12, 20, 16, 0.96));
406
+ border: 1px solid rgba(158, 202, 176, 0.24);
407
+ box-shadow: 0 12px 26px rgba(0, 0, 0, 0.34), inset 0 0 0 1px rgba(255, 255, 255, 0.03);
408
+ }
409
+
410
+ .serviceNode.isHigh {
411
+ box-shadow: 0 12px 26px rgba(0, 0, 0, 0.34), 0 0 24px rgba(56, 201, 135, 0.08);
241
412
  }
242
413
 
243
414
  .logo {
244
- width: 36px;
245
- height: 36px;
246
- border-radius: 8px;
247
- background: #172033;
415
+ width: 48px;
416
+ height: 48px;
417
+ border-radius: 12px;
418
+ background: #24302b;
248
419
  color: #fff;
249
420
  display: grid;
250
421
  place-items: center;
251
422
  overflow: hidden;
252
423
  flex: 0 0 auto;
253
424
  font-size: 12px;
254
- font-weight: 700;
425
+ font-weight: 800;
255
426
  }
256
427
 
257
428
  .logo img {
258
- max-width: 22px;
259
- max-height: 22px;
429
+ width: 100%;
430
+ height: 100%;
260
431
  object-fit: contain;
432
+ padding: 7px;
433
+ filter: brightness(0) invert(1);
434
+ }
435
+
436
+ .logo-cloud {
437
+ background: #2d72f6;
438
+ }
439
+
440
+ .logo-developer-tools {
441
+ background: #202833;
442
+ }
443
+
444
+ .logo-billing {
445
+ background: #635bff;
446
+ }
447
+
448
+ .logo-email,
449
+ .logo-collaboration {
450
+ background: #f45d48;
451
+ }
452
+
453
+ .logo-ai {
454
+ background: #1f7a55;
455
+ }
456
+
457
+ .logo-monitoring {
458
+ background: #6b4bd8;
459
+ }
460
+
461
+ .logo-auth {
462
+ background: #28a37a;
463
+ }
464
+
465
+ .logo-database {
466
+ background: #f57c00;
261
467
  }
262
468
 
263
469
  .nameBlock {
@@ -266,47 +472,108 @@ p {
266
472
 
267
473
  .nameBlock strong {
268
474
  display: block;
475
+ overflow: hidden;
476
+ text-overflow: ellipsis;
269
477
  white-space: nowrap;
478
+ font-size: 15px;
479
+ }
480
+
481
+ .nameBlock span {
482
+ display: block;
483
+ margin-top: 4px;
270
484
  overflow: hidden;
271
485
  text-overflow: ellipsis;
486
+ white-space: nowrap;
487
+ font-size: 12px;
488
+ text-transform: capitalize;
272
489
  }
273
490
 
274
- .nameBlock span,
275
- .card p {
276
- color: #64748b;
491
+ .statusDot {
492
+ width: 8px;
493
+ height: 8px;
494
+ border-radius: 999px;
495
+ background: #eac94f;
496
+ box-shadow: 0 0 12px rgba(234, 201, 79, 0.9);
497
+ }
498
+
499
+ .statusDot.active {
500
+ background: #69f6b1;
501
+ box-shadow: 0 0 12px rgba(105, 246, 177, 0.9);
502
+ }
503
+
504
+ .empty {
505
+ position: absolute;
506
+ left: 50%;
507
+ top: 50%;
508
+ transform: translate(-50%, 90px);
509
+ display: grid;
510
+ gap: 6px;
511
+ text-align: center;
512
+ color: rgba(238, 247, 242, 0.74);
513
+ }
514
+
515
+ .legend {
516
+ position: absolute;
517
+ left: 50%;
518
+ bottom: 28px;
519
+ transform: translateX(-50%);
520
+ display: flex;
521
+ gap: 20px;
522
+ padding: 8px 16px;
523
+ border-radius: 999px;
524
+ background: rgba(8, 15, 12, 0.82);
525
+ border: 1px solid rgba(158, 202, 176, 0.18);
277
526
  font-size: 12px;
527
+ font-weight: 800;
278
528
  }
279
529
 
280
- .category {
530
+ .legend span {
281
531
  display: inline-flex;
282
532
  align-items: center;
283
- height: 22px;
284
- padding: 0 8px;
533
+ gap: 7px;
534
+ white-space: nowrap;
535
+ }
536
+
537
+ .legend b {
538
+ width: 8px;
539
+ height: 8px;
285
540
  border-radius: 999px;
286
- background: #edf2ff;
287
- color: #334155;
288
- font-size: 12px;
289
- margin-bottom: 10px;
290
541
  }
291
542
 
292
- .empty {
293
- background: #fff;
294
- border: 1px dashed #cbd5e1;
295
- border-radius: 8px;
296
- padding: 24px;
543
+ .legend .blue {
544
+ background: #3d8cff;
545
+ }
546
+
547
+ .legend .green {
548
+ background: #69f6b1;
549
+ }
550
+
551
+ .legend .yellow {
552
+ background: #eac94f;
297
553
  }
298
554
 
299
- @media (max-width: 720px) {
555
+ @media (max-width: 880px) {
300
556
  .topbar {
301
- display: block;
557
+ height: auto;
558
+ align-items: flex-start;
559
+ flex-direction: column;
560
+ }
561
+
562
+ .toolbar {
563
+ flex-wrap: wrap;
564
+ }
565
+
566
+ .mapCanvas {
567
+ min-height: 980px;
302
568
  }
303
569
 
304
- h1 {
305
- font-size: 34px;
570
+ .serviceNode {
571
+ width: 210px;
306
572
  }
307
573
 
308
- .metrics {
309
- grid-template-columns: 1fr;
574
+ .sectionLabel {
575
+ align-items: flex-start;
576
+ flex-direction: column;
310
577
  }
311
578
  }
312
579
  `;
@@ -395,7 +662,7 @@ async function main() {
395
662
  await writeProjectFile(options.output, 'vite.config.js', viteConfig());
396
663
  await writeProjectFile(options.output, 'src/main.jsx', mainJsx());
397
664
  await writeProjectFile(options.output, 'src/styles.css', stylesCss());
398
- await writeProjectFile(options.output, 'srcservicemap-data.json', `${JSON.stringify(graph, null, 2)}\n`);
665
+ await writeProjectFile(options.output, 'src/servicemap-data.json', `${JSON.stringify(graph, null, 2)}\n`);
399
666
  await writeProjectFile(options.output, 'src-tauri/tauri.conf.json', tauriConfig(graph));
400
667
  await writeProjectFile(options.output, 'src-tauri/Cargo.toml', cargoToml());
401
668
  await writeProjectFile(options.output, 'src-tauri/build.rs', buildRs());
@@ -49,33 +49,64 @@ function escapeHtml(value) {
49
49
 
50
50
  function pageCss() {
51
51
  return `
52
- :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
53
- body { margin: 0; background: #f7f8fb; color: #172033; }
54
- .servicemap-shell { max-width: 1180px; margin: 0 auto; padding: 40px 24px 56px; }
55
- .servicemap-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-end; margin-bottom: 24px; }
56
- h1 { margin: 0 0 8px; font-size: clamp(30px, 4vw, 48px); line-height: 1; letter-spacing: 0; }
57
- p { margin: 0; color: #5f6b7c; line-height: 1.55; }
58
- .summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 22px 0; }
59
- .metric, .service-card { background: #fff; border: 1px solid #e3e8ef; border-radius: 8px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); }
60
- .metric { padding: 16px; }
61
- .metric strong { display: block; font-size: 26px; }
62
- .metric span { color: #64748b; font-size: 13px; }
63
- .service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
64
- .service-card { padding: 14px; min-height: 150px; }
65
- .service-top { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
66
- .logo { width: 36px; height: 36px; border-radius: 8px; background: #172033; display: grid; place-items: center; overflow: hidden; flex: 0 0 auto; }
67
- .logo img { max-width: 22px; max-height: 22px; object-fit: contain; }
68
- .service-name { min-width: 0; }
69
- .service-name strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
70
- .service-name span, .evidence { color: #64748b; font-size: 12px; }
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
- .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; }
74
- .empty { background: #fff; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 24px; }
75
- @media (max-width: 720px) { .servicemap-header { display: block; } .summary { grid-template-columns: 1fr; } }
52
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
53
+ * { box-sizing: border-box; }
54
+ body { margin: 0; background: #020806; color: #eff8f2; }
55
+ .servicemap-shell { min-height: 100vh; overflow: hidden; background: #030806; }
56
+ .servicemap-topbar { height: 88px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 28px; border-bottom: 1px solid rgba(255,255,255,0.08); background: #222823; }
57
+ .brand { display: flex; align-items: center; gap: 16px; min-width: 0; }
58
+ .brand-mark { width: 48px; height: 48px; border-radius: 10px; border: 1px solid #2a7958; background: #164d39; color: #7cf1b5; display: grid; place-items: center; box-shadow: 0 0 26px rgba(82,255,166,0.16); font-size: 22px; }
59
+ .brand h1 { margin: 0; font-size: 20px; line-height: 1; letter-spacing: 0; }
60
+ .brand p, .top-meta { margin: 6px 0 0; color: #8aa097; font-size: 14px; }
61
+ .top-actions { display: flex; align-items: center; gap: 12px; color: #8da59a; font-size: 14px; white-space: nowrap; }
62
+ .mode-pill { border-radius: 8px; background: #0b130f; color: #7cf1b5; padding: 8px 12px; }
63
+ .map { position: relative; height: calc(100vh - 88px); min-height: 760px; background: radial-gradient(circle at 50% 52%, rgba(41, 96, 69, 0.22), transparent 24%), radial-gradient(circle at 1px 1px, rgba(88, 160, 120, 0.2) 1px, transparent 1px), #020806; background-size: auto, 28px 28px, auto; }
64
+ .intro { position: absolute; left: 32px; top: 32px; z-index: 20; }
65
+ .intro strong { display: block; color: #779286; font-size: 12px; letter-spacing: 0.22em; text-transform: uppercase; }
66
+ .intro span { display: block; margin-top: 8px; color: #53665d; font-size: 14px; }
67
+ .edges { position: absolute; inset: 0; width: 100%; height: 100%; }
68
+ .hub { position: absolute; left: 50%; top: 50%; z-index: 20; width: 132px; height: 132px; transform: translate(-50%, -50%); border-radius: 24px; border: 1px solid #315c47; background: #09120d; display: grid; place-items: center; box-shadow: 0 0 0 9px rgba(79,183,128,0.12), 0 0 42px rgba(100,255,174,0.16); }
69
+ .hub-inner { width: 92px; height: 92px; border-radius: 18px; background: #fbfbf7; color: #111612; display: grid; place-items: center; font-size: 40px; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); }
70
+ .service-node { position: absolute; z-index: 10; width: 250px; height: 74px; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 16px; padding: 0 16px; border-radius: 8px; border: 1px solid #274036; background: rgba(16,24,19,0.95); box-shadow: 0 0 0 1px rgba(140,255,190,0.05), 0 18px 44px rgba(0,0,0,0.38); }
71
+ .logo { width: 48px; height: 48px; flex: 0 0 auto; border-radius: 8px; display: grid; place-items: center; overflow: hidden; color: #fff; font-weight: 700; }
72
+ .logo img { width: 28px; height: 28px; object-fit: contain; filter: invert(1); }
73
+ .service-copy { min-width: 0; }
74
+ .service-copy strong { display: block; color: #f2faf5; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
75
+ .service-copy span { display: block; margin-top: 5px; color: #7f9188; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
76
+ .status-dot { margin-left: auto; width: 8px; height: 8px; flex: 0 0 auto; border-radius: 999px; box-shadow: 0 0 14px currentColor; }
77
+ .status-high { color: #65f0ad; background: #65f0ad; }
78
+ .status-medium, .status-low { color: #f1c85b; background: #f1c85b; }
79
+ .legend { position: absolute; left: 50%; bottom: 32px; z-index: 20; transform: translateX(-50%); display: flex; align-items: center; gap: 20px; border: 1px solid rgba(255,255,255,0.1); border-radius: 999px; background: rgba(11,20,15,0.9); color: #789086; padding: 8px 20px; font-size: 12px; }
80
+ .legend i { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; }
81
+ .scan-foot { position: absolute; right: 32px; bottom: 32px; z-index: 20; color: #53665d; text-align: right; font-size: 12px; line-height: 1.6; }
82
+ .access-warning { position: absolute; left: 32px; right: 32px; bottom: 88px; z-index: 25; max-width: 720px; border: 1px solid rgba(241,200,91,0.42); border-radius: 8px; background: rgba(31,27,12,0.92); color: #f7daa0; padding: 12px 14px; font-size: 14px; line-height: 1.45; }
83
+ @media (max-width: 920px) {
84
+ .map { min-height: 1120px; }
85
+ .service-node { width: 220px; }
86
+ .top-actions { display: none; }
87
+ }
76
88
  `;
77
89
  }
78
90
 
91
+ const palette = ['#ff7a00', '#f48120', '#111827', '#6d5dfc', '#4285f4', '#5e5ce6', '#e5e7eb', '#7c3aed', '#24292f', '#22c55e'];
92
+ const layout = [
93
+ { x: 17, y: 30 }, { x: 17, y: 55 }, { x: 35, y: 28 }, { x: 35, y: 72 }, { x: 25, y: 82 },
94
+ { x: 59, y: 34 }, { x: 79, y: 23 }, { x: 79, y: 48 }, { x: 80, y: 64 }, { x: 62, y: 73 },
95
+ { x: 19, y: 18 }, { x: 81, y: 82 }, { x: 41, y: 17 }, { x: 61, y: 18 }, { x: 43, y: 84 }, { x: 57, y: 84 },
96
+ ];
97
+
98
+ function edgePath(node) {
99
+ const isLeft = node.x < 50;
100
+ const fromX = isLeft ? node.x + 8 : node.x - 8;
101
+ const endX = isLeft ? 47 : 53;
102
+ const midX = isLeft ? Math.max(fromX + 7, 42) : Math.min(fromX - 7, 58);
103
+ return `M ${fromX} ${node.y} H ${midX} V 50 H ${endX}`;
104
+ }
105
+
106
+ function initials(name) {
107
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
108
+ }
109
+
79
110
  function htmlBody(graph) {
80
111
  const services = graph.services ?? [];
81
112
  const access = graph.access ?? {};
@@ -83,43 +114,56 @@ function htmlBody(graph) {
83
114
  const accessNotice = hasAccessRequest && access.enforced !== true
84
115
  ? '<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
116
  : '';
86
- const serviceCards = services.length
87
- ? services.map((service) => {
88
- const evidence = service.evidence?.[0];
89
- const evidenceText = evidence ? `${evidence.type}: ${evidence.value}` : 'No evidence captured';
90
- return `
91
- <article class="service-card">
92
- <div class="service-top">
93
- <div class="logo">${service.iconUrl ? `<img src="${escapeHtml(service.iconUrl)}" alt="">` : escapeHtml(service.name.slice(0, 2).toUpperCase())}</div>
94
- <div class="service-name">
95
- <strong>${escapeHtml(service.name)}</strong>
96
- <span>${escapeHtml(service.confidence)} confidence</span>
97
- </div>
98
- </div>
99
- <span class="badge">${escapeHtml(service.category)}</span>
100
- <p class="evidence">${escapeHtml(evidenceText)}</p>
101
- </article>
102
- `;
103
- }).join('')
104
- : '<div class="empty">No external services were detected yet.</div>';
117
+ const nodes = services.slice(0, layout.length).map((service, index) => ({
118
+ ...service,
119
+ ...layout[index],
120
+ color: palette[index % palette.length],
121
+ }));
122
+ const edges = nodes.map((node) => `<path d="${edgePath(node)}" fill="none" stroke="rgba(103, 130, 118, 0.48)" stroke-width="0.16" vector-effect="non-scaling-stroke"></path>`).join('');
123
+ const serviceNodes = nodes.map((service) => `
124
+ <article class="service-node" style="left:${service.x}%;top:${service.y}%">
125
+ <div class="logo" style="background:${service.color}">${service.iconUrl ? `<img src="${escapeHtml(service.iconUrl)}" alt="">` : escapeHtml(initials(service.name))}</div>
126
+ <div class="service-copy">
127
+ <strong>${escapeHtml(service.name)}</strong>
128
+ <span>${escapeHtml(service.category)}</span>
129
+ </div>
130
+ <i class="status-dot status-${escapeHtml(service.confidence)}"></i>
131
+ </article>
132
+ `).join('');
105
133
 
106
134
  return `
107
135
  <main class="servicemap-shell">
108
- <section class="servicemap-header">
109
- <div>
110
- <h1>Internal Servicemap</h1>
111
- <p>${escapeHtml(graph.project?.name ?? 'This project')} uses these detected services.</p>
136
+ <header class="servicemap-topbar">
137
+ <div class="brand">
138
+ <div class="brand-mark">⌘</div>
139
+ <div>
140
+ <h1>servicemap</h1>
141
+ <p>${escapeHtml(graph.project?.name ?? 'Project')} command center</p>
142
+ </div>
112
143
  </div>
113
- <p>Generated ${escapeHtml(new Date(graph.generatedAt).toLocaleString())}</p>
114
- </section>
115
- <section class="summary" aria-label="Service map summary">
116
- <div class="metric"><strong>${services.length}</strong><span>services detected</span></div>
117
- <div class="metric"><strong>${graph.summary?.highConfidenceCount ?? 0}</strong><span>high confidence</span></div>
118
- <div class="metric"><strong>${escapeHtml(graph.project?.framework ?? 'unknown')}</strong><span>framework</span></div>
119
- </section>
120
- ${accessNotice}
121
- <section class="service-grid">
122
- ${serviceCards}
144
+ <div class="top-actions">
145
+ <span>${services.length} services</span>
146
+ <span class="mode-pill">2D</span>
147
+ </div>
148
+ </header>
149
+ <section class="map">
150
+ <div class="intro">
151
+ <strong>${escapeHtml(graph.project?.name ?? 'Project')} infrastructure</strong>
152
+ <span>Select a service to trace its dependencies</span>
153
+ </div>
154
+ <svg class="edges" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${edges}</svg>
155
+ <div class="hub"><div class="hub-inner">⌘</div></div>
156
+ ${serviceNodes || '<div class="intro"><span>No external services were detected yet.</span></div>'}
157
+ ${accessNotice}
158
+ <div class="legend">
159
+ <span><i style="background:#4a7cff"></i>Cloud</span>
160
+ <span><i style="background:#8be4b2"></i>Product</span>
161
+ <span><i style="background:#65f0ad"></i>Active path</span>
162
+ </div>
163
+ <footer class="scan-foot">
164
+ <div>Generated ${escapeHtml(new Date(graph.generatedAt).toLocaleString())}</div>
165
+ <div>${services.length} services detected</div>
166
+ </footer>
123
167
  </section>
124
168
  </main>
125
169
  `;
@@ -10,6 +10,7 @@ const logoDatabasePath = resolve(skillRoot, 'assets/service-logo-database.json')
10
10
  const defaultOutput = '.servicemap/internal-servicemap.json';
11
11
 
12
12
  const ignoredDirs = new Set([
13
+ '.servicemap',
13
14
  '.git',
14
15
  '.next',
15
16
  '.nuxt',
@@ -17,12 +18,14 @@ const ignoredDirs = new Set([
17
18
  'build',
18
19
  'coverage',
19
20
  'dist',
21
+ 'graphify-out',
20
22
  'node_modules',
23
+ 'servicemap-desktop',
21
24
  'target',
22
25
  'vendor',
23
26
  ]);
24
27
 
25
- const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.astro', '.vue', '.svelte', '.py', '.rb', '.go', '.rs', '.php', '.java']);
28
+ const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.astro', '.vue', '.svelte', '.py', '.rb', '.go', '.rs', '.php', '.java', '.swift']);
26
29
  const configFileNames = new Set([
27
30
  'Dockerfile',
28
31
  'docker-compose.yml',
@@ -187,7 +190,7 @@ function buildGeneratedRules(database) {
187
190
  iconUrl: entry.iconUrl,
188
191
  packages: [entry.key, entry.name.toLowerCase().replace(/\s+/g, ''), ...entry.aliases.map((alias) => alias.toLowerCase())].filter(Boolean),
189
192
  envPrefixes: [entry.key.replace(/[^a-z0-9]/gi, '_').toUpperCase()],
190
- domains: [entry.sourceHost].filter(Boolean),
193
+ domains: [],
191
194
  }));
192
195
  }
193
196