servicemap 0.1.13 → 0.1.15

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.
Files changed (2) hide show
  1. package/bin/servicemap.mjs +192 -21
  2. package/package.json +1 -1
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
- import { cp, mkdir, readFile, rm } from 'node:fs/promises';
4
+ import { cp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
  import readline from 'node:readline';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
10
10
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const repositoryUrl = 'https://github.com/douglasbrown11/servicemap.git';
12
+ const stableSourceRoot = () => resolve(homedir(), '.servicemap/source');
11
13
  const scanScript = resolve(packageRoot, 'servicemap/scripts/scan-project.mjs');
12
14
  const pageScript = resolve(packageRoot, 'servicemap/scripts/generate-page.mjs');
13
15
  const desktopScript = resolve(packageRoot, 'servicemap/scripts/generate-desktop-app.mjs');
@@ -162,6 +164,7 @@ function parseArgs() {
162
164
  agent: 'codex',
163
165
  targets: null,
164
166
  skillsDir: null,
167
+ installMethod: null,
165
168
  allowedEmails: [],
166
169
  invokedWithoutArgs: args.length === 0,
167
170
  explicitCommand: false,
@@ -190,6 +193,7 @@ function parseArgs() {
190
193
  }
191
194
  if (arg === '--agent') options.agent = args[++index] ?? options.agent;
192
195
  if (arg === '--targets') options.targets = (args[++index] ?? '').split(',').map((target) => target.trim()).filter(Boolean);
196
+ if (arg === '--install-method') options.installMethod = args[++index] ?? null;
193
197
  if (arg === '--allow-email') {
194
198
  options.allowedEmails.push(args[++index] ?? '');
195
199
  options.generationRequested = true;
@@ -217,7 +221,7 @@ Usage:
217
221
  servicemap
218
222
  servicemap generate [--root <project>] [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
219
223
  servicemap --root <project> [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
220
- servicemap install-skill [--survey] [--agent codex|claude|grok|all] [--targets <ids>]
224
+ servicemap install-skill [--survey] [--agent codex|claude|grok|all] [--targets <ids>] [--install-method copy|symlink]
221
225
  servicemap skills install [--survey] [--agent codex|claude|grok|all]
222
226
 
223
227
  Examples:
@@ -292,20 +296,41 @@ function skillRootsForTargets(options, targets) {
292
296
  const visibleUniversalLimit = 12;
293
297
  const additionalAgentWindowSize = 8;
294
298
  const dim = (value) => `\x1b[2m${value}\x1b[22m`;
299
+ const green = (value) => `\x1b[32m${value}\x1b[39m`;
300
+ const cyan = (value) => `\x1b[36m${value}\x1b[39m`;
301
+
302
+ function timelineStep(text) {
303
+ console.log(`${green('◇')} ${text}`);
304
+ console.log(dim('│'));
305
+ }
306
+
307
+ function timelineActive(text) {
308
+ console.log(`${green('◆')} ${text}`);
309
+ console.log(dim('│'));
310
+ }
311
+
312
+ function timelineLine(text = '') {
313
+ console.log(`${dim('│')} ${text}`);
314
+ }
295
315
 
296
316
  function renderSurvey(selected, cursor) {
297
317
  console.clear();
298
- console.log('Which agents do you want to install Servicemap to?\n');
299
- console.log('— Universal (.agents/skills) — always included —');
318
+ timelineStep(cyan('servicemap'));
319
+ timelineStep(`Source: ${repositoryUrl}`);
320
+ timelineStep(`Stable source: ${stableSourceRoot()}`);
321
+ timelineActive('Which agents do you want to install Servicemap to?');
322
+ timelineLine('Universal (.agents/skills) — always included');
300
323
  for (const agent of universalAgents.slice(0, visibleUniversalLimit)) {
301
- console.log(` • ${agent}`);
324
+ timelineLine(`• ${agent}`);
302
325
  }
303
326
  const hiddenUniversalCount = universalAgents.length - visibleUniversalLimit;
304
327
  if (hiddenUniversalCount > 0) {
305
- console.log(` ...and ${hiddenUniversalCount} more`);
328
+ timelineLine(`...and ${hiddenUniversalCount} more`);
306
329
  }
307
- console.log('\n— Additional agents —');
308
- console.log('Use ↑/↓, Space to select, Enter to install.\n');
330
+ timelineLine('');
331
+ timelineLine('Additional agents');
332
+ timelineLine('Use ↑/↓, Space to select, Enter to install.');
333
+ timelineLine('');
309
334
 
310
335
  const halfWindow = Math.floor(additionalAgentWindowSize / 2);
311
336
  let start = Math.max(0, cursor - halfWindow);
@@ -318,13 +343,13 @@ function renderSurvey(selected, cursor) {
318
343
  const index = start + offset;
319
344
  const pointer = index === cursor ? '>' : ' ';
320
345
  const checked = selected.has(target.id) ? 'x' : ' ';
321
- console.log(`${pointer} [${checked}] ${target.label} ${dim(`(${target.root()})`)}`);
346
+ timelineLine(`${pointer} [${checked}] ${target.label} ${dim(`(${target.root()})`)}`);
322
347
  });
323
348
 
324
349
  if (beforeCount > 0 || afterCount > 0) {
325
350
  const before = beforeCount > 0 ? `↑ ${beforeCount} more` : '';
326
351
  const after = afterCount > 0 ? `↓ ${afterCount} more` : '';
327
- console.log(` ${dim([before, after].filter(Boolean).join(' '))}`);
352
+ timelineLine(dim([before, after].filter(Boolean).join(' ')));
328
353
  }
329
354
 
330
355
  if (selected.size > 0) {
@@ -333,7 +358,8 @@ function renderSurvey(selected, cursor) {
333
358
  .map((target) => target.label);
334
359
  const preview = selectedLabels.slice(0, 3).join(', ');
335
360
  const remainder = selectedLabels.length > 3 ? ` +${selectedLabels.length - 3} more` : '';
336
- console.log(`\nSelected: ${preview}${remainder}`);
361
+ timelineLine('');
362
+ timelineLine(`Selected: ${preview}${remainder}`);
337
363
  }
338
364
  }
339
365
 
@@ -378,12 +404,157 @@ function runTargetSurvey() {
378
404
  });
379
405
  }
380
406
 
407
+ function renderInstallMethodSurvey(cursor) {
408
+ console.clear();
409
+ const methods = [
410
+ { id: 'symlink', label: 'Symlink', note: 'Recommended, one stable source for easy updates' },
411
+ { id: 'copy', label: 'Copy', note: 'Standalone copies in each selected agent folder' },
412
+ ];
413
+
414
+ timelineStep(cyan('servicemap'));
415
+ timelineStep(`Source: ${repositoryUrl}`);
416
+ timelineStep(`Stable source: ${stableSourceRoot()}`);
417
+ timelineStep('Agents selected');
418
+ timelineActive('Installation method');
419
+ methods.forEach((method, index) => {
420
+ const pointer = index === cursor ? '>' : ' ';
421
+ const selected = index === cursor ? '●' : '○';
422
+ timelineLine(`${pointer} ${selected} ${method.label} ${dim(`(${method.note})`)}`);
423
+ });
424
+ timelineLine(dim('↑/↓ to navigate • Enter to confirm'));
425
+ }
426
+
427
+ function runInstallMethodSurvey() {
428
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
429
+ return Promise.resolve('copy');
430
+ }
431
+
432
+ const methods = ['symlink', 'copy'];
433
+ let cursor = 0;
434
+
435
+ return new Promise((resolvePromise) => {
436
+ readline.emitKeypressEvents(process.stdin);
437
+ process.stdin.setRawMode(true);
438
+ renderInstallMethodSurvey(cursor);
439
+
440
+ const onKeypress = (_str, key) => {
441
+ if (key.name === 'up') cursor = Math.max(0, cursor - 1);
442
+ if (key.name === 'down') cursor = Math.min(methods.length - 1, cursor + 1);
443
+ if (key.name === 'return') {
444
+ process.stdin.setRawMode(false);
445
+ process.stdin.off('keypress', onKeypress);
446
+ console.log('');
447
+ resolvePromise(methods[cursor]);
448
+ return;
449
+ }
450
+ if (key.ctrl && key.name === 'c') {
451
+ process.stdin.setRawMode(false);
452
+ process.stdin.off('keypress', onKeypress);
453
+ process.exit(130);
454
+ }
455
+
456
+ renderInstallMethodSurvey(cursor);
457
+ };
458
+
459
+ process.stdin.on('keypress', onKeypress);
460
+ });
461
+ }
462
+
463
+ function runSilent(command, args, options = {}) {
464
+ return new Promise((resolvePromise, reject) => {
465
+ const child = spawn(command, args, {
466
+ stdio: 'ignore',
467
+ ...options,
468
+ });
469
+
470
+ child.on('error', reject);
471
+ child.on('exit', (code) => {
472
+ if (code === 0) resolvePromise();
473
+ else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
474
+ });
475
+ });
476
+ }
477
+
478
+ async function withSpinner(message, action) {
479
+ if (!process.stdout.isTTY) {
480
+ return action();
481
+ }
482
+
483
+ const frames = ['◒', '◐', '◓', '◑'];
484
+ let frame = 0;
485
+ let dots = '';
486
+ process.stdout.write('\x1b[?25l');
487
+ const interval = setInterval(() => {
488
+ process.stdout.write(`\r\x1b[J${frames[frame]} ${message}${dots}`);
489
+ frame = (frame + 1) % frames.length;
490
+ if (frame === 0) dots = dots.length >= 5 ? '' : `${dots}.`;
491
+ }, 120);
492
+
493
+ try {
494
+ return await action();
495
+ } finally {
496
+ clearInterval(interval);
497
+ process.stdout.write('\r\x1b[J\x1b[?25h');
498
+ }
499
+ }
500
+
501
+ async function prepareStableSource(options) {
502
+ const sourceRoot = stableSourceRoot();
503
+ if (options.dryRun) {
504
+ timelineStep(`Would prepare stable source at ${sourceRoot}`);
505
+ return resolve(sourceRoot, 'servicemap');
506
+ }
507
+
508
+ timelineStep(`Source: ${repositoryUrl}`);
509
+ await mkdir(dirname(sourceRoot), { recursive: true });
510
+
511
+ try {
512
+ if (existsSync(join(sourceRoot, '.git'))) {
513
+ await withSpinner('Updating repository…', () => runSilent('git', ['pull', '--ff-only'], { cwd: sourceRoot }));
514
+ timelineStep('Repository updated');
515
+ } else {
516
+ await rm(sourceRoot, { recursive: true, force: true });
517
+ await withSpinner('Cloning repository…', () => runSilent('git', ['clone', '--depth', '1', repositoryUrl, sourceRoot]));
518
+ timelineStep('Repository cloned');
519
+ }
520
+ } catch (_error) {
521
+ await rm(sourceRoot, { recursive: true, force: true });
522
+ await cp(packageRoot, sourceRoot, { recursive: true });
523
+ timelineStep('Repository unavailable; copied packaged source');
524
+ }
525
+
526
+ return resolve(sourceRoot, 'servicemap');
527
+ }
528
+
529
+ async function installIntoRoot(root, source, method, options) {
530
+ const destination = join(root, 'servicemap');
531
+ if (options.dryRun) {
532
+ const verb = method === 'symlink' ? 'link' : 'copy';
533
+ console.log(`Would ${verb} Servicemap skill to ${destination}`);
534
+ return;
535
+ }
536
+
537
+ await mkdir(root, { recursive: true });
538
+ await rm(destination, { recursive: true, force: true });
539
+
540
+ if (method === 'symlink') {
541
+ await symlink(source, destination, 'dir');
542
+ console.log(`Linked Servicemap skill to ${destination}`);
543
+ return;
544
+ }
545
+
546
+ await cp(source, destination, { recursive: true });
547
+ console.log(`Installed Servicemap skill to ${destination}`);
548
+ }
549
+
381
550
  async function installSkill(options) {
382
- const source = resolve(packageRoot, 'servicemap');
551
+ let source = resolve(packageRoot, 'servicemap');
383
552
  const selectedTargets = options.survey && !options.yes
384
553
  ? [{ id: 'universal', label: 'Universal', root: universalRoot }, ...await runTargetSurvey()]
385
554
  : targetsForOptions(options);
386
555
  const roots = skillRootsForTargets(options, selectedTargets);
556
+ const method = options.installMethod
557
+ ?? (options.survey && !options.yes ? await runInstallMethodSurvey() : 'copy');
387
558
 
388
559
  if (!existsSync(source)) {
389
560
  throw new Error(`Bundled skill folder not found: ${source}`);
@@ -394,19 +565,19 @@ async function installSkill(options) {
394
565
  return;
395
566
  }
396
567
 
397
- if (options.dryRun) {
398
- for (const root of roots) {
399
- console.log(`Would install Servicemap skill to ${join(root, 'servicemap')}`);
568
+ if (!['copy', 'symlink'].includes(method)) {
569
+ throw new Error(`Unknown install method "${method}". Use copy or symlink.`);
570
+ }
571
+
572
+ if (method === 'symlink') {
573
+ source = await prepareStableSource(options);
574
+ if (!existsSync(source) && !options.dryRun) {
575
+ throw new Error(`Stable skill source not found: ${source}`);
400
576
  }
401
- return;
402
577
  }
403
578
 
404
579
  for (const root of roots) {
405
- const destination = join(root, 'servicemap');
406
- await mkdir(root, { recursive: true });
407
- await rm(destination, { recursive: true, force: true });
408
- await cp(source, destination, { recursive: true });
409
- console.log(`Installed Servicemap skill to ${destination}`);
580
+ await installIntoRoot(root, source, method, options);
410
581
  }
411
582
 
412
583
  console.log('Restart your AI app if /servicemap does not appear immediately.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {