docugate 0.2.6 → 0.2.9

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/dist/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import { check } from './check.js';
5
6
  import { loadConfig } from './config.js';
6
- import { choose, initFlow, makeDefaultIO } from './init.js';
7
+ import { initFlow } from './init.js';
7
8
  import { buildOpenApi, isCurrent, writeOpenApi } from './openapi.js';
8
9
  import { BobKeyMissingError, BobMissingError, tourInit } from './tour-init.js';
9
10
  import { tourInstall, hasPillSnippet, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
@@ -206,18 +207,16 @@ async function main() {
206
207
  yes: args.bools.has('yes'),
207
208
  port,
208
209
  });
209
- // The pill only shows while the tour server runs, so setup ends by
210
- // offering to start it rather than leaving one more command to remember.
210
+ // The Tour button only shows while the tour server runs, so setup starts
211
+ // it in the background instead of asking: the next thing the person
212
+ // does is open their app, and the button is there.
211
213
  const where = result.alreadySetUp ?? root;
212
214
  const hasTour = result.role === 'frontend' || result.role === 'both';
213
- if (hasTour && process.stdin.isTTY && !args.bools.has('yes')) {
214
- const io = makeDefaultIO();
215
- const pick = await choose(io, 'Start the tour server now, so the pill shows in your app?', [
216
- 'Yes, start it (leave this terminal open, then run your app as usual)',
217
- 'Later: docugate tour serve',
218
- ]);
219
- if (pick === 1)
220
- await serveTour(where, port);
215
+ if (hasTour && !args.bools.has('no-serve')) {
216
+ const started = await startServerInBackground(where, port);
217
+ console.log(dim(started
218
+ ? ` The tour server is running in the background (port ${port}). After a restart: docugate tour serve`
219
+ : ` Start the tour server with docugate tour serve, then open your app.`));
221
220
  }
222
221
  return;
223
222
  }
@@ -288,6 +287,10 @@ async function main() {
288
287
  // already / no-op
289
288
  console.log(` ${dim('unchanged')} ${result.target}`);
290
289
  }
290
+ if (result.hookAction === 'inserted')
291
+ console.log(` ${green('installed')} package.json ${dim(`(${result.hook}: the tour server starts with your app)`)}`);
292
+ if (result.hookAction === 'removed')
293
+ console.log(` ${green('removed')} package.json ${dim(`(${result.hook})`)}`);
291
294
  return;
292
295
  }
293
296
  case 'init': {
@@ -344,7 +347,16 @@ ${dim(`Bob: ${parts.join(', ')}`)}`);
344
347
  return;
345
348
  }
346
349
  case 'serve': {
347
- await serveTour(root, parseInt(one('port') ?? '4178', 10));
350
+ const port = parseInt(one('port') ?? '4178', 10);
351
+ if (args.bools.has('background')) {
352
+ // Run from the app's predev script: start (or reuse) the server,
353
+ // say so in one line, and hand the terminal back to the app.
354
+ mkdirSync(join(root, '.docugate', 'tour'), { recursive: true });
355
+ const up = await startServerInBackground(root, port);
356
+ console.log(up ? `${green('DocuGate')} tour ready on port ${port}` : `${yellow('DocuGate')} could not start the tour server. Run docugate tour serve to see why.`);
357
+ return;
358
+ }
359
+ await serveTour(root, port);
348
360
  return;
349
361
  }
350
362
  case 'export': {
@@ -369,6 +381,37 @@ main().catch((error) => {
369
381
  console.error(`${red('error')} ${error.message}`);
370
382
  process.exit(1);
371
383
  });
384
+ /**
385
+ * Starts `docugate tour serve` detached, so it outlives this command. When one
386
+ * is already answering on the port, that one is kept. True when a server is
387
+ * answering afterwards.
388
+ */
389
+ async function startServerInBackground(root, port) {
390
+ const answering = async () => {
391
+ try {
392
+ const res = await fetch(`http://127.0.0.1:${port}/pill.js`, { signal: AbortSignal.timeout(800) });
393
+ return res.ok;
394
+ }
395
+ catch {
396
+ return false;
397
+ }
398
+ };
399
+ if (await answering())
400
+ return true;
401
+ const child = spawn(process.execPath, [process.argv[1], 'tour', 'serve', '--port', String(port)], {
402
+ cwd: root,
403
+ detached: true,
404
+ stdio: 'ignore',
405
+ windowsHide: true,
406
+ });
407
+ child.unref();
408
+ for (let i = 0; i < 20; i++) {
409
+ await new Promise((r) => setTimeout(r, 150));
410
+ if (await answering())
411
+ return true;
412
+ }
413
+ return false;
414
+ }
372
415
  /**
373
416
  * Serves the tour to the pill until Ctrl-C. Starts even when there is no tour
374
417
  * yet: the pill then shows on every page, and its inspector writes the first
package/dist/init.js CHANGED
@@ -53,6 +53,21 @@ export function makeDefaultIO() {
53
53
  });
54
54
  });
55
55
  },
56
+ askSecret(question, defaultAnswer) {
57
+ // Keys must never be shown on screen, where screenshots and recordings
58
+ // pick them up: print the question, then echo nothing while typing.
59
+ return new Promise((resolve) => {
60
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
61
+ const muted = rl;
62
+ process.stdout.write(`${question} `);
63
+ muted._writeToOutput = () => undefined;
64
+ rl.question('', (answer) => {
65
+ rl.close();
66
+ process.stdout.write('[hidden]\n');
67
+ resolve(answer.trim() || defaultAnswer);
68
+ });
69
+ });
70
+ },
56
71
  print(line) {
57
72
  console.log(line);
58
73
  },
@@ -114,7 +129,7 @@ async function createDocsSpace(io, projectName, baseUrl, options) {
114
129
  if (fix !== 1)
115
130
  return undefined;
116
131
  (options.openUrl ?? openInBrowser)(`${baseUrl}/api/auth/github?install=1`);
117
- await io.ask(` On GitHub, add ${repo.repo} (or choose All repositories) and save. Then press Enter here`, '');
132
+ await io.ask(` On GitHub, add ${repo.repo} (or choose All repositories) and save. Then press Enter here:`, '');
118
133
  }
119
134
  }
120
135
  return undefined;
@@ -167,13 +182,15 @@ async function askForBobKey(io, openUrl) {
167
182
  io.print(` In your Bob instance: API keys, then create an Inference key.`);
168
183
  io.print(` ${BOB_KEYS_URL}`);
169
184
  (openUrl ?? openInBrowser)(BOB_KEYS_URL);
170
- const key = (await io.ask(' Paste the key and press Enter (used for this run only, never saved)', '')).trim();
185
+ const key = (await (io.askSecret ?? io.ask)(' Paste the key and press Enter (hidden, used for this run only, never saved):', '')).trim();
171
186
  if (!key) {
172
187
  io.print(' No key pasted. Skipped the tour: run docugate tour init when you have one.');
173
188
  return false;
174
189
  }
175
190
  useBobKeyForThisRun(key);
176
- io.print(' Using the key for this run only. To keep it for next time, set it in your environment:');
191
+ // Say it arrived, without showing it: the last four characters and the length.
192
+ io.print(` ${green('✓')} Key received (…${key.slice(-4)}, ${key.length} characters). Using it for this run only.`);
193
+ io.print(' To keep it for next time, set it in your environment:');
177
194
  io.print(process.platform === 'win32'
178
195
  ? ' [Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User") (then open a new terminal)'
179
196
  : ' export BOB_API_KEY=<key> (add it to your shell profile)');
@@ -231,16 +248,36 @@ export function guessRole(root) {
231
248
  return 'backend';
232
249
  return undefined;
233
250
  }
251
+ // ── Look ───────────────────────────────────────────────────────────────────
252
+ // Color only in a real terminal, and never when NO_COLOR is set, so logs and
253
+ // CI stay plain text.
254
+ const colorOn = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
255
+ const paint = (code) => (text) => (colorOn() ? `\x1b[${code}m${text}\x1b[0m` : text);
256
+ const bold = paint('1');
257
+ const dimmed = paint('2');
258
+ const gold = paint('38;2;244;196;63');
259
+ const cream = paint('38;2;245;240;227');
260
+ const green = paint('32');
261
+ /** DocuGate's owl, drawn small: cream eyes, gold beak. */
262
+ export function banner() {
263
+ return [
264
+ '',
265
+ ` ${dimmed('╭─────╮')}`,
266
+ ` ${dimmed('│')} ${cream('◉ ◉')} ${dimmed('│')} ${bold('DocuGate')}`,
267
+ ` ${dimmed('│')} ${gold('▾')} ${dimmed('│')} ${dimmed('Tours and docs for your codebase')}`,
268
+ ` ${dimmed('╰─────╯')}`,
269
+ ];
270
+ }
234
271
  /**
235
272
  * A numbered choice: prints the options, and takes only a number. Enter picks
236
273
  * the default. Nobody types a word during setup.
237
274
  */
238
275
  export async function choose(io, question, options, fallback = 1) {
239
276
  io.print('');
240
- io.print(` ${question}`);
241
- options.forEach((option, i) => io.print(` ${i + 1} ${option}`));
277
+ io.print(` ${bold(question)}`);
278
+ options.forEach((option, i) => io.print(` ${gold(String(i + 1))} ${option}`));
242
279
  for (;;) {
243
- const answer = (await io.ask(` Choose 1-${options.length} [${fallback}]`, String(fallback))).trim();
280
+ const answer = (await io.ask(` ${dimmed(`Choose 1-${options.length} [${fallback}]`)}:`, String(fallback))).trim();
244
281
  const n = Number(answer);
245
282
  if (Number.isInteger(n) && n >= 1 && n <= options.length)
246
283
  return n;
@@ -275,6 +312,9 @@ export async function initFlow(root, options = {}) {
275
312
  // One project gets one setup and one tour. Run inside frontend/ of a
276
313
  // project already set up at its root, and init points there instead of
277
314
  // starting a second one.
315
+ if (io.isTTY)
316
+ for (const line of banner())
317
+ io.print(line);
278
318
  const parent = setUpAbove(root);
279
319
  if (parent) {
280
320
  io.print(` This project is already set up in ${parent}.`);
@@ -475,7 +515,7 @@ export async function initFlow(root, options = {}) {
475
515
  'IBM Bob (uses Bobcoins)',
476
516
  'IBM watsonx (coming soon)',
477
517
  'Skip, I will write it in the browser',
478
- ], 3);
518
+ ]);
479
519
  if (pick === 2)
480
520
  io.print(' IBM watsonx is coming soon. Skipped for now: run docugate tour init when you are ready.');
481
521
  runTour = pick === 1;
@@ -511,21 +551,24 @@ export async function initFlow(root, options = {}) {
511
551
  try {
512
552
  tourResult = tourInit(root, { runBob: options.runBob });
513
553
  stop();
514
- io.print(` IBM Bob wrote the tour.`);
554
+ io.print(` ${green('✓')} IBM Bob wrote the tour (${tourResult.added.length} screen${tourResult.added.length === 1 ? '' : 's'}).`);
515
555
  }
516
556
  catch (error) {
517
557
  stop();
518
558
  // Bob missing or failed — describe what went wrong, then continue.
519
559
  if (error instanceof BobKeyMissingError) {
520
560
  tourError =
521
- `IBM Bob did not accept the key. Check BOB_API_KEY holds an Inference key from bob.ibm.com, then run docugate tour init.`;
561
+ `✗ IBM Bob did not accept this key. Check it is an Inference key from bob.ibm.com, then run docugate tour init.`;
522
562
  }
523
563
  else if (error instanceof BobMissingError) {
524
564
  tourError =
525
565
  `Bob Shell is not installed. Install it, sign in, then run docugate tour init.`;
526
566
  }
527
567
  else {
528
- tourError = `Tour init failed: ${error.message}. Run docugate tour init to retry.`;
568
+ // One calm line; the technical detail only when asked for.
569
+ tourError = `IBM Bob couldn't write the tour this time. The pill still works: add steps in the browser, or run docugate tour init later.`;
570
+ if (process.env.DOCUGATE_DEBUG)
571
+ io.print(` detail ${error.message}`);
529
572
  }
530
573
  io.print(` warning ${tourError}`);
531
574
  }
@@ -571,7 +614,38 @@ export async function initFlow(root, options = {}) {
571
614
  io.print(` installed pill in ${installResult.target}`);
572
615
  if (installResult?.action === 'already')
573
616
  io.print(` pill already in ${installResult.target}`);
617
+ const hasPill = installResult?.action === 'inserted' || installResult?.action === 'already';
618
+ const final = loadConfig(root).config;
619
+ const freshness = FRESHNESS.find((f) => f.value === final.freshness);
620
+ const rows = [
621
+ ['Repository', role ? role.charAt(0).toUpperCase() + role.slice(1) : 'not set'],
622
+ ['Docs', final.space ?? 'no space yet'],
623
+ ['Checks', freshness ? freshness.label.split(' (')[0] : 'not set'],
624
+ ];
625
+ if (role !== 'backend') {
626
+ rows.push(['Tour', tourResult ? `IBM Bob wrote ${tourResult.added.length} screen${tourResult.added.length === 1 ? '' : 's'}` : 'add steps in the browser']);
627
+ rows.push(['Tour button', hasPill && installResult?.target ? installResult.target : 'not added']);
628
+ if (installResult?.hook)
629
+ rows.push(['Starts with', `npm run ${installResult.hook.replace(/^pre/, '')}`]);
630
+ }
631
+ io.print(``);
632
+ io.print(` ${dimmed('╭')} ${bold('DocuGate setup')}`);
633
+ rows.forEach(([label, value], i) => {
634
+ const branch = i === rows.length - 1 ? '╰' : '├';
635
+ io.print(` ${dimmed(branch)} ${label.padEnd(12)} ${green(value)}`);
636
+ });
574
637
  io.print(``);
575
- io.print(`Next: docugate tour serve`);
638
+ if (role === 'backend') {
639
+ io.print(` ${green('✓')} ${bold('DocuGate is set up for this backend.')}`);
640
+ }
641
+ else if (hasPill) {
642
+ io.print(` ${green('✓')} ${bold('DocuGate is set up.')}`);
643
+ const run = installResult?.hook ? `npm run ${installResult.hook.replace(/^pre/, '')}` : 'npm run dev';
644
+ io.print(` Start your project as usual (${gold(run)}) and open it:`);
645
+ io.print(` the ${gold('Tour')} button is in the bottom-right corner.`);
646
+ }
647
+ else {
648
+ io.print(` ${green('✓')} ${bold('DocuGate is set up.')} Run docugate tour install to add the Tour button to your app.`);
649
+ }
576
650
  return { ...base, role, tourResult, tourError, installResult, connectError };
577
651
  }
package/dist/tour-init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
5
  import { isMap, isSeq, parseDocument } from 'yaml';
6
6
  // `docugate tour init` writes the first tour of an app by asking IBM Bob
7
7
  // (through Bob Shell) to read the code. Bob does the reading; this file makes
@@ -86,14 +86,37 @@ export function readBobKey() {
86
86
  export function useBobKeyForThisRun(key) {
87
87
  process.env.BOB_API_KEY = key;
88
88
  }
89
+ /**
90
+ * On Windows, `bob` is an npm .cmd shim, and a .cmd only runs through cmd.exe,
91
+ * which splits a path at its spaces ("C:\Users\GUSENGA Thierry") and cannot
92
+ * carry the prompt's line breaks. The shim only starts Node on Bob Shell's
93
+ * script, so run that script with Node directly: no shell, nothing mangled.
94
+ */
95
+ export function bobScript(pathEnv = process.env.PATH ?? '') {
96
+ for (const dir of pathEnv.split(';').filter(Boolean)) {
97
+ const shim = join(dir, 'bob.cmd');
98
+ if (!existsSync(shim))
99
+ continue;
100
+ const match = readFileSync(shim, 'utf8').match(/"%dp0%\\([^"]+\.js)"/);
101
+ if (!match)
102
+ continue;
103
+ const script = join(dir, match[1]);
104
+ if (existsSync(script))
105
+ return script;
106
+ }
107
+ return undefined;
108
+ }
89
109
  const defaultRunBob = (args, cwd) => {
90
- const result = spawnSync('bob', args, {
91
- cwd,
92
- encoding: 'utf8',
93
- // `bob` is a .cmd shim on Windows, which only runs through a shell.
94
- shell: process.platform === 'win32',
95
- maxBuffer: 64 * 1024 * 1024,
96
- });
110
+ const script = process.platform === 'win32' ? bobScript() : undefined;
111
+ const result = script
112
+ ? spawnSync(process.execPath, [script, ...args], { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
113
+ : spawnSync('bob', args, {
114
+ cwd,
115
+ encoding: 'utf8',
116
+ // No shim script found on Windows: fall back to the shell.
117
+ shell: process.platform === 'win32',
118
+ maxBuffer: 64 * 1024 * 1024,
119
+ });
97
120
  if (result.error && result.error.code === 'ENOENT') {
98
121
  throw new BobMissingError('Bob Shell is not installed.');
99
122
  }
@@ -24,6 +24,59 @@ export const CANDIDATES = [
24
24
  'app/index.html',
25
25
  'public/index.html',
26
26
  ];
27
+ /** What the app's own start script runs first, so the tour server comes up with it. */
28
+ export const SERVE_HOOK = 'docugate tour serve --background';
29
+ /**
30
+ * Adds `docugate tour serve --background` as the `predev` script (or
31
+ * `prestart`, for apps started with npm start), so running the project the
32
+ * usual way also starts the tour server. npm runs pre-scripts by itself, on
33
+ * every platform. An existing pre-script keeps running first. Only this
34
+ * repository's package.json, and only its scripts, are touched.
35
+ */
36
+ export function installStartHook(root, options = {}) {
37
+ const path = join(root, 'package.json');
38
+ if (!existsSync(path))
39
+ return { action: 'none' };
40
+ const text = readFileSync(path, 'utf8');
41
+ let pkg;
42
+ try {
43
+ pkg = JSON.parse(text);
44
+ }
45
+ catch {
46
+ return { action: 'none' };
47
+ }
48
+ const scripts = pkg.scripts ?? {};
49
+ const base = scripts.dev ? 'dev' : scripts.start ? 'start' : null;
50
+ if (!base)
51
+ return { action: 'none' };
52
+ const hook = `pre${base}`;
53
+ const port = options.port ?? DEFAULT_PORT;
54
+ const command = port === DEFAULT_PORT ? SERVE_HOOK : `${SERVE_HOOK} --port ${port}`;
55
+ const existing = scripts[hook];
56
+ if (options.remove) {
57
+ if (!existing?.includes(SERVE_HOOK))
58
+ return { hook, action: 'already' };
59
+ const rest = existing
60
+ .split('&&')
61
+ .map((part) => part.trim())
62
+ .filter((part) => !part.startsWith(SERVE_HOOK))
63
+ .join(' && ');
64
+ if (rest)
65
+ scripts[hook] = rest;
66
+ else
67
+ delete scripts[hook];
68
+ }
69
+ else {
70
+ if (existing?.includes(SERVE_HOOK))
71
+ return { hook, action: 'already' };
72
+ scripts[hook] = existing ? `${existing} && ${command}` : command;
73
+ }
74
+ pkg.scripts = scripts;
75
+ const indent = text.match(/^[ \t]+(?=")/m)?.[0] ?? ' ';
76
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
77
+ writeFileSync(path, JSON.stringify(pkg, null, indent).replace(/\n/g, eol) + eol);
78
+ return { hook, action: options.remove ? 'removed' : 'inserted' };
79
+ }
27
80
  /** Returns true if any candidate file already contains the pill snippet. */
28
81
  export function hasPillSnippet(root) {
29
82
  for (const rel of CANDIDATES) {
@@ -100,6 +153,11 @@ function removeSnippet(text, open, close) {
100
153
  * already present is a no-op.
101
154
  */
102
155
  export function tourInstall(root, options = {}) {
156
+ const result = installSnippet(root, options);
157
+ const started = installStartHook(root, options);
158
+ return { ...result, hook: started.hook, hookAction: started.action };
159
+ }
160
+ function installSnippet(root, options) {
103
161
  const port = options.port ?? DEFAULT_PORT;
104
162
  const remove = options.remove ?? false;
105
163
  for (const rel of CANDIDATES) {
@@ -375,6 +375,19 @@ export function startTourServer(root, port) {
375
375
  else if (req.method === 'GET' && path === '/pill.js') {
376
376
  handleGetPill(res);
377
377
  }
378
+ else if (req.method === 'GET' && path === '/') {
379
+ // Somebody opened the tour server itself. Say what it is and where to go.
380
+ const screens = loadTours(root).length;
381
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
382
+ res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>DocuGate tour server</title>
383
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0F1014;color:#F5F0E3;font:15px/1.6 system-ui,sans-serif}
384
+ main{max-width:440px;padding:32px;border:1px solid #25273a;border-radius:16px;background:#15173a}
385
+ h1{font-size:20px;margin:0 0 8px}p{margin:0 0 12px;color:#aaa597}b{color:#F4C43F;font-weight:600}
386
+ .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#45d483;margin-right:8px}</style></head>
387
+ <body><main><h1><span class="dot"></span>The DocuGate tour server is running</h1>
388
+ <p>This is not a page to open. It feeds the Tour button in your app (${screens} screen${screens === 1 ? '' : 's'} in <b>.docugate/tour/</b>).</p>
389
+ <p>Open <b>your app</b> instead, for example <b>http://localhost:5173</b> or <b>http://localhost:3000</b>. The Tour button is in the bottom-right corner.</p></main></body></html>`);
390
+ }
378
391
  else {
379
392
  sendText(res, 404, 'Not found');
380
393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docugate",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
4
4
  "description": "Command-line tools for DocuGate: set up, check and prepare the documentation DocuGate reads straight from your repository.",
5
5
  "type": "module",
6
6
  "bin": {