docugate 0.2.6 → 0.2.8

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';
@@ -12,57 +13,57 @@ import { startTourServer } from './tour-server.js';
12
13
  import { login, clearCredentials, loadCredentials } from './auth.js';
13
14
  import { BASE_URL, getSession } from './api.js';
14
15
  const SITE = 'https://www.trydocugate.site';
15
- const HELP = `docugate: set up and check the docs DocuGate reads from your repository
16
-
17
- Usage
18
- docugate <command> [options]
19
-
20
- Commands
21
- init Set up this repository for DocuGate (interactive)
22
- login Sign in to DocuGate with GitHub
23
- logout Sign out and delete saved credentials
24
- whoami Print the currently signed-in user
25
- check Check the docs the way DocuGate will read them
26
- openapi Prepare an OpenAPI spec for DocuGate's API reference
27
- tour init Write the first tour of this app with IBM Bob
28
- tour install Add the pill loader to your app
29
- tour serve Start a local server that serves tour data for the pill
30
- tour export Write every tour screen to a single JSON file
31
-
32
- init
33
- --dir <folder> Docs folder (default: docs)
34
- --title <text> Space title (default: this folder's name)
35
- --role <role> Repository role: frontend, backend, or both
36
- --yes Accept every default without asking
37
-
38
- check
39
- --dir <folder> Check this folder instead of docugate.json's docsDir
40
- --strict Treat warnings as errors
41
-
42
- openapi
43
- --from <file|url> The spec to read (or api.generate.from in docugate.json)
44
- --out <file> Where to write it (default: openapi.json)
45
- --include <glob> Keep only paths that match; repeatable
46
- --exclude <glob> Drop paths that match; repeatable
47
- --redact <name> Remove a property from every schema and example; repeatable
48
- --check Fail if the file is out of date, instead of writing it
49
-
50
- tour init
51
- --max-cost <n> Most Bobcoins the run may spend (default: 3)
52
- --team-id <id> Bob team to run under, for API keys that need one
53
-
54
- tour install
55
- --port <n> Port the pill server listens on (default: 4178)
56
- --remove Remove the pill snippet instead of adding it
57
-
58
- tour serve
59
- --port <n> Port to listen on (default: 4178)
60
-
61
- tour export <file>
62
- Write all tour screens to <file> as JSON.
63
-
64
- Run it from the root of your repository.
65
- ${SITE}
16
+ const HELP = `docugate: set up and check the docs DocuGate reads from your repository
17
+
18
+ Usage
19
+ docugate <command> [options]
20
+
21
+ Commands
22
+ init Set up this repository for DocuGate (interactive)
23
+ login Sign in to DocuGate with GitHub
24
+ logout Sign out and delete saved credentials
25
+ whoami Print the currently signed-in user
26
+ check Check the docs the way DocuGate will read them
27
+ openapi Prepare an OpenAPI spec for DocuGate's API reference
28
+ tour init Write the first tour of this app with IBM Bob
29
+ tour install Add the pill loader to your app
30
+ tour serve Start a local server that serves tour data for the pill
31
+ tour export Write every tour screen to a single JSON file
32
+
33
+ init
34
+ --dir <folder> Docs folder (default: docs)
35
+ --title <text> Space title (default: this folder's name)
36
+ --role <role> Repository role: frontend, backend, or both
37
+ --yes Accept every default without asking
38
+
39
+ check
40
+ --dir <folder> Check this folder instead of docugate.json's docsDir
41
+ --strict Treat warnings as errors
42
+
43
+ openapi
44
+ --from <file|url> The spec to read (or api.generate.from in docugate.json)
45
+ --out <file> Where to write it (default: openapi.json)
46
+ --include <glob> Keep only paths that match; repeatable
47
+ --exclude <glob> Drop paths that match; repeatable
48
+ --redact <name> Remove a property from every schema and example; repeatable
49
+ --check Fail if the file is out of date, instead of writing it
50
+
51
+ tour init
52
+ --max-cost <n> Most Bobcoins the run may spend (default: 3)
53
+ --team-id <id> Bob team to run under, for API keys that need one
54
+
55
+ tour install
56
+ --port <n> Port the pill server listens on (default: 4178)
57
+ --remove Remove the pill snippet instead of adding it
58
+
59
+ tour serve
60
+ --port <n> Port to listen on (default: 4178)
61
+
62
+ tour export <file>
63
+ Write all tour screens to <file> as JSON.
64
+
65
+ Run it from the root of your repository.
66
+ ${SITE}
66
67
  `;
67
68
  const color = process.stdout.isTTY && !process.env.NO_COLOR;
68
69
  const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
@@ -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
  }
@@ -298,28 +297,28 @@ async function main() {
298
297
  }
299
298
  catch (error) {
300
299
  if (error instanceof BobKeyMissingError) {
301
- console.error(`${red('error')} Bob Shell needs an API key to run on its own (your IDE sign-in is not used here).
300
+ console.error(`${red('error')} Bob Shell needs an API key to run on its own (your IDE sign-in is not used here).
302
301
  ` +
303
- `1. At https://bob.ibm.com, open your instance, then API keys, and create an Inference key.
302
+ `1. At https://bob.ibm.com, open your instance, then API keys, and create an Inference key.
304
303
  ` +
305
- `2. Save it as the BOB_API_KEY environment variable, then open a new terminal:
304
+ `2. Save it as the BOB_API_KEY environment variable, then open a new terminal:
306
305
  ` +
307
- ` PowerShell [Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User")
306
+ ` PowerShell [Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User")
308
307
  ` +
309
- ` macOS/Linux export BOB_API_KEY=<key> (add it to your shell profile)
308
+ ` macOS/Linux export BOB_API_KEY=<key> (add it to your shell profile)
310
309
  ` +
311
310
  dim('Keep the key out of your repository. https://bob.ibm.com/docs/ide/account/api-keys'));
312
311
  process.exit(2);
313
312
  }
314
313
  if (!(error instanceof BobMissingError))
315
314
  throw error;
316
- console.error(`${red('error')} Bob Shell is not installed, and tours are written by IBM Bob.
315
+ console.error(`${red('error')} Bob Shell is not installed, and tours are written by IBM Bob.
317
316
  ` +
318
- `Install it (Node 24 or later), sign in, then run this again:
317
+ `Install it (Node 24 or later), sign in, then run this again:
319
318
  ` +
320
- ` Windows powershell -c "irm -Uri https://bob.ibm.com/download/bobshell.ps1 | iex"
319
+ ` Windows powershell -c "irm -Uri https://bob.ibm.com/download/bobshell.ps1 | iex"
321
320
  ` +
322
- ` macOS/Linux curl -fsSL https://bob.ibm.com/download/bobshell.sh | bash
321
+ ` macOS/Linux curl -fsSL https://bob.ibm.com/download/bobshell.sh | bash
323
322
  ` +
324
323
  dim('https://bob.ibm.com/docs/shell/getting-started/install-and-setup'));
325
324
  process.exit(2);
@@ -338,7 +337,7 @@ async function main() {
338
337
  stats?.durationMs !== undefined ? `${Math.round(stats.durationMs / 1000)}s` : '',
339
338
  stats?.cost !== undefined ? `${stats.cost} Bobcoins` : '',
340
339
  ].filter(Boolean);
341
- console.log(`
340
+ console.log(`
342
341
  ${dim(`Bob: ${parts.join(', ')}`)}`);
343
342
  console.log('Next: read the new files, fix anything Bob got wrong, and commit them. They are yours now.');
344
343
  return;
@@ -369,6 +368,37 @@ main().catch((error) => {
369
368
  console.error(`${red('error')} ${error.message}`);
370
369
  process.exit(1);
371
370
  });
371
+ /**
372
+ * Starts `docugate tour serve` detached, so it outlives this command. When one
373
+ * is already answering on the port, that one is kept. True when a server is
374
+ * answering afterwards.
375
+ */
376
+ async function startServerInBackground(root, port) {
377
+ const answering = async () => {
378
+ try {
379
+ const res = await fetch(`http://127.0.0.1:${port}/pill.js`, { signal: AbortSignal.timeout(800) });
380
+ return res.ok;
381
+ }
382
+ catch {
383
+ return false;
384
+ }
385
+ };
386
+ if (await answering())
387
+ return true;
388
+ const child = spawn(process.execPath, [process.argv[1], 'tour', 'serve', '--port', String(port)], {
389
+ cwd: root,
390
+ detached: true,
391
+ stdio: 'ignore',
392
+ windowsHide: true,
393
+ });
394
+ child.unref();
395
+ for (let i = 0; i < 20; i++) {
396
+ await new Promise((r) => setTimeout(r, 150));
397
+ if (await answering())
398
+ return true;
399
+ }
400
+ return false;
401
+ }
372
402
  /**
373
403
  * Serves the tour to the pill until Ctrl-C. Starts even when there is no tour
374
404
  * 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,7 +182,7 @@ 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;
@@ -231,16 +246,36 @@ export function guessRole(root) {
231
246
  return 'backend';
232
247
  return undefined;
233
248
  }
249
+ // ── Look ───────────────────────────────────────────────────────────────────
250
+ // Color only in a real terminal, and never when NO_COLOR is set, so logs and
251
+ // CI stay plain text.
252
+ const colorOn = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
253
+ const paint = (code) => (text) => (colorOn() ? `\x1b[${code}m${text}\x1b[0m` : text);
254
+ const bold = paint('1');
255
+ const dimmed = paint('2');
256
+ const gold = paint('38;2;244;196;63');
257
+ const cream = paint('38;2;245;240;227');
258
+ const green = paint('32');
259
+ /** DocuGate's owl, drawn small: cream eyes, gold beak. */
260
+ export function banner() {
261
+ return [
262
+ '',
263
+ ` ${dimmed('╭─────╮')}`,
264
+ ` ${dimmed('│')} ${cream('◉ ◉')} ${dimmed('│')} ${bold('DocuGate')}`,
265
+ ` ${dimmed('│')} ${gold('▾')} ${dimmed('│')} ${dimmed('Tours and docs for your codebase')}`,
266
+ ` ${dimmed('╰─────╯')}`,
267
+ ];
268
+ }
234
269
  /**
235
270
  * A numbered choice: prints the options, and takes only a number. Enter picks
236
271
  * the default. Nobody types a word during setup.
237
272
  */
238
273
  export async function choose(io, question, options, fallback = 1) {
239
274
  io.print('');
240
- io.print(` ${question}`);
241
- options.forEach((option, i) => io.print(` ${i + 1} ${option}`));
275
+ io.print(` ${bold(question)}`);
276
+ options.forEach((option, i) => io.print(` ${gold(String(i + 1))} ${option}`));
242
277
  for (;;) {
243
- const answer = (await io.ask(` Choose 1-${options.length} [${fallback}]`, String(fallback))).trim();
278
+ const answer = (await io.ask(` ${dimmed(`Choose 1-${options.length} [${fallback}]`)}:`, String(fallback))).trim();
244
279
  const n = Number(answer);
245
280
  if (Number.isInteger(n) && n >= 1 && n <= options.length)
246
281
  return n;
@@ -275,6 +310,9 @@ export async function initFlow(root, options = {}) {
275
310
  // One project gets one setup and one tour. Run inside frontend/ of a
276
311
  // project already set up at its root, and init points there instead of
277
312
  // starting a second one.
313
+ if (io.isTTY)
314
+ for (const line of banner())
315
+ io.print(line);
278
316
  const parent = setUpAbove(root);
279
317
  if (parent) {
280
318
  io.print(` This project is already set up in ${parent}.`);
@@ -475,7 +513,7 @@ export async function initFlow(root, options = {}) {
475
513
  'IBM Bob (uses Bobcoins)',
476
514
  'IBM watsonx (coming soon)',
477
515
  'Skip, I will write it in the browser',
478
- ], 3);
516
+ ]);
479
517
  if (pick === 2)
480
518
  io.print(' IBM watsonx is coming soon. Skipped for now: run docugate tour init when you are ready.');
481
519
  runTour = pick === 1;
@@ -525,7 +563,10 @@ export async function initFlow(root, options = {}) {
525
563
  `Bob Shell is not installed. Install it, sign in, then run docugate tour init.`;
526
564
  }
527
565
  else {
528
- tourError = `Tour init failed: ${error.message}. Run docugate tour init to retry.`;
566
+ // One calm line; the technical detail only when asked for.
567
+ 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.`;
568
+ if (process.env.DOCUGATE_DEBUG)
569
+ io.print(` detail ${error.message}`);
529
570
  }
530
571
  io.print(` warning ${tourError}`);
531
572
  }
@@ -571,7 +612,35 @@ export async function initFlow(root, options = {}) {
571
612
  io.print(` installed pill in ${installResult.target}`);
572
613
  if (installResult?.action === 'already')
573
614
  io.print(` pill already in ${installResult.target}`);
615
+ const hasPill = installResult?.action === 'inserted' || installResult?.action === 'already';
616
+ const final = loadConfig(root).config;
617
+ const freshness = FRESHNESS.find((f) => f.value === final.freshness);
618
+ const rows = [
619
+ ['Repository', role ? role.charAt(0).toUpperCase() + role.slice(1) : 'not set'],
620
+ ['Docs', final.space ?? 'no space yet'],
621
+ ['Checks', freshness ? freshness.label.split(' (')[0] : 'not set'],
622
+ ];
623
+ if (role !== 'backend') {
624
+ rows.push(['Tour', tourResult ? `IBM Bob wrote ${tourResult.added.length} screen${tourResult.added.length === 1 ? '' : 's'}` : 'add steps in the browser']);
625
+ rows.push(['Tour button', hasPill && installResult?.target ? installResult.target : 'not added']);
626
+ }
627
+ io.print(``);
628
+ io.print(` ${dimmed('╭')} ${bold('DocuGate setup')}`);
629
+ rows.forEach(([label, value], i) => {
630
+ const branch = i === rows.length - 1 ? '╰' : '├';
631
+ io.print(` ${dimmed(branch)} ${label.padEnd(12)} ${green(value)}`);
632
+ });
574
633
  io.print(``);
575
- io.print(`Next: docugate tour serve`);
634
+ if (role === 'backend') {
635
+ io.print(` ${green('✓')} ${bold('DocuGate is set up for this backend.')}`);
636
+ }
637
+ else if (hasPill) {
638
+ io.print(` ${green('✓')} ${bold('DocuGate is set up.')}`);
639
+ io.print(` Start your project as usual (for example ${gold('npm run dev')}) and open it:`);
640
+ io.print(` the ${gold('Tour')} button is in the bottom-right corner.`);
641
+ }
642
+ else {
643
+ io.print(` ${green('✓')} ${bold('DocuGate is set up.')} Run docugate tour install to add the Tour button to your app.`);
644
+ }
576
645
  return { ...base, role, tourResult, tourError, installResult, connectError };
577
646
  }
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
  }
@@ -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.8",
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": {