docugate 0.2.5 → 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/README.md CHANGED
@@ -44,14 +44,15 @@ It asks, one at a time:
44
44
  | --- | --- |
45
45
  | What is in this repository? | **1** Frontend, **2** Backend, or **3** Both (a `frontend/` and a `backend/` folder in one repository). It suggests one from your folders. |
46
46
  | Sign in to DocuGate? | **1** opens your browser to sign in with GitHub. You come back to your editor when it is done. **2** skips it; `docugate login` does it later. |
47
- | Which DocuGate space should document this repository? | **1** creates a new space for it, or pick one of yours. A space already reading this repository is marked. |
47
+ | Where should the documentation live? | **1** creates a new space, with a new GitHub repository on your account to hold its markdown (`<project>-docs`, with a first page). Or pick one of your spaces. The pill then links to the published docs. |
48
48
  | How often should DocuGate check your docs still match the code? | On every push, daily (suggested, fewer AI runs), weekly, or never. |
49
49
  | Set up the tour: which AI should write it? | **1** IBM Bob reads your code and writes the tour. **2** IBM watsonx is coming soon. **3** skips it; you can write the tour in the browser. Frontend and both only. |
50
50
  | Add the DocuGate pill to your app? | **1** adds a few lines to your `index.html` or Next.js layout that load the pill in development only. |
51
51
  | Start the tour server now? | **1** starts it, so the pill shows right away. |
52
52
 
53
- IBM Bob reads its API key from the `BOB_API_KEY` environment variable. If it is
54
- not set, setup opens the page where you create a key and lets you paste it for
53
+ IBM Bob reads its API key from the `BOB_API_KEY` environment variable. If you
54
+ saved it under another name (say `IBM_BOB_KEY`), setup finds it and asks before
55
+ using it. If there is none, setup opens the page where you create a key and lets you paste it for
55
56
  that one run; DocuGate never saves it. To set it for good (then open a new terminal):
56
57
 
57
58
  ```powershell
package/dist/api.js CHANGED
@@ -7,6 +7,8 @@ const ERROR_MESSAGES = {
7
7
  pro_required: 'Private repositories need DocuGate Pro',
8
8
  no_repo_access: "DocuGate can't see this repository. Install the DocuGate GitHub App on it first",
9
9
  slug_taken: 'You already have a space with this name',
10
+ repo_exists: 'You already have a repository with this name on GitHub',
11
+ app_permission: "DocuGate isn't allowed to create repositories on your account yet. Accept the DocuGate app's updated permissions at https://github.com/settings/installations, then try again",
10
12
  github_required: 'Sign in with GitHub to create a space',
11
13
  };
12
14
  function friendlyError(code) {
@@ -45,7 +47,7 @@ export async function apiFetch(path, init = {}) {
45
47
  // ignore JSON parse failure
46
48
  }
47
49
  if (code)
48
- throw new Error(friendlyError(code));
50
+ throw Object.assign(new Error(friendlyError(code)), { code });
49
51
  throw new Error(`DocuGate request failed with status ${res.status}`);
50
52
  }
51
53
  return res;
@@ -65,6 +67,17 @@ export async function createSpace(repo, name, docsDir) {
65
67
  });
66
68
  return (await res.json()).space;
67
69
  }
70
+ /**
71
+ * Creates a repository on the person's GitHub account, as them. With `docs`,
72
+ * it is a documentation repository that starts with docs/index.md.
73
+ */
74
+ export async function createRepo(name, isPrivate, docs) {
75
+ const res = await apiFetch('/api/repos', {
76
+ method: 'POST',
77
+ body: JSON.stringify({ name, private: isPrivate, docs: Boolean(docs), title: docs?.title }),
78
+ });
79
+ return (await res.json());
80
+ }
68
81
  export async function connectRepo(spaceId, repo, docsDir) {
69
82
  await apiFetch(`/api/spaces/${spaceId}/sources`, {
70
83
  method: 'POST',
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
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
4
4
  import { basename, dirname, join } from 'node:path';
5
5
  import { CONFIG_FILE, loadConfig } from './config.js';
6
6
  import { tourInit, BobMissingError, BobKeyMissingError, readBobKey, useBobKeyForThisRun } from './tour-init.js';
7
+ import { bobKeyCandidates, startSpinner } from './spinner.js';
7
8
  import { tourInstall, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
8
9
  import { login, loadCredentials } from './auth.js';
9
10
  /**
@@ -52,11 +53,87 @@ export function makeDefaultIO() {
52
53
  });
53
54
  });
54
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
+ },
55
71
  print(line) {
56
72
  console.log(line);
57
73
  },
58
74
  };
59
75
  }
76
+ /**
77
+ * A new space and the repository that holds its docs: the repository is
78
+ * created on the person's account with docs/index.md in it, then the space is
79
+ * made from it. Returns the space, or undefined when something stopped it
80
+ * (every reason is printed).
81
+ */
82
+ async function createDocsSpace(io, projectName, baseUrl, options) {
83
+ const visibility = await choose(io, 'Who can read the docs repository on GitHub?', [
84
+ 'Public',
85
+ 'Private (a space from a private repository needs DocuGate Pro)',
86
+ ]);
87
+ const repoName = `${projectName.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-|-$/g, '')}-docs`;
88
+ const stop = startSpinner(['Creating the docs repository on GitHub', 'Writing its first page', 'Almost there']);
89
+ let repo;
90
+ try {
91
+ repo = await (options.createDocsRepoFn ?? (async (name, priv, title) => {
92
+ const { createRepo } = await import('./api.js');
93
+ return createRepo(name, priv, { title });
94
+ }))(repoName, visibility === 2, projectName);
95
+ stop();
96
+ io.print(` Created ${repo.url}`);
97
+ }
98
+ catch (err) {
99
+ stop();
100
+ io.print(` warning ${err.message}`);
101
+ return undefined;
102
+ }
103
+ const createFn = options.createSpaceFn ?? (async (r, name, dir) => {
104
+ const { createSpace } = await import('./api.js');
105
+ return createSpace(r, name, dir);
106
+ });
107
+ // A space only ever reads a repository DocuGate can see. One installed on
108
+ // selected repositories may not include the new one yet: offer the install
109
+ // and try again once.
110
+ for (let attempt = 0; attempt < 2; attempt++) {
111
+ const stopSpace = startSpinner(['Setting up your space', 'Reading the docs repository']);
112
+ try {
113
+ const space = await createFn(repo.repo, projectName, 'docs');
114
+ stopSpace();
115
+ io.print(` Created space "${space.name}"`);
116
+ if (repo.seeded === false)
117
+ io.print(` Add markdown files to docs/ in ${repo.repo} and they become pages.`);
118
+ return space;
119
+ }
120
+ catch (err) {
121
+ stopSpace();
122
+ io.print(` warning ${err.message}`);
123
+ if (err.code !== 'no_repo_access' || attempt > 0)
124
+ return undefined;
125
+ const fix = await choose(io, `The DocuGate GitHub App can't see ${repo.repo} yet.`, [
126
+ 'Add it to the app (opens GitHub), then try again',
127
+ 'Skip for now',
128
+ ]);
129
+ if (fix !== 1)
130
+ return undefined;
131
+ (options.openUrl ?? openInBrowser)(`${baseUrl}/api/auth/github?install=1`);
132
+ await io.ask(` On GitHub, add ${repo.repo} (or choose All repositories) and save. Then press Enter here:`, '');
133
+ }
134
+ }
135
+ return undefined;
136
+ }
60
137
  /**
61
138
  * The nearest folder above `root`, inside the same Git repository, that has
62
139
  * DocuGate set up already (a docugate.json), if any.
@@ -105,7 +182,7 @@ async function askForBobKey(io, openUrl) {
105
182
  io.print(` In your Bob instance: API keys, then create an Inference key.`);
106
183
  io.print(` ${BOB_KEYS_URL}`);
107
184
  (openUrl ?? openInBrowser)(BOB_KEYS_URL);
108
- 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();
109
186
  if (!key) {
110
187
  io.print(' No key pasted. Skipped the tour: run docugate tour init when you have one.');
111
188
  return false;
@@ -169,69 +246,42 @@ export function guessRole(root) {
169
246
  return 'backend';
170
247
  return undefined;
171
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
+ }
172
269
  /**
173
270
  * A numbered choice: prints the options, and takes only a number. Enter picks
174
271
  * the default. Nobody types a word during setup.
175
272
  */
176
273
  export async function choose(io, question, options, fallback = 1) {
177
274
  io.print('');
178
- io.print(` ${question}`);
179
- 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}`));
180
277
  for (;;) {
181
- 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();
182
279
  const n = Number(answer);
183
280
  if (Number.isInteger(n) && n >= 1 && n <= options.length)
184
281
  return n;
185
282
  io.print(` Type a number from 1 to ${options.length}.`);
186
283
  }
187
284
  }
188
- function run(cmd, args, cwd) {
189
- try {
190
- const out = execFileSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
191
- return { ok: true, out: out.trim() };
192
- }
193
- catch (err) {
194
- const e = err;
195
- return { ok: false, out: (e.stderr ?? e.message).trim() };
196
- }
197
- }
198
- /**
199
- * A space reads from a GitHub repository. When the project has none yet, offer
200
- * to create one with the GitHub CLI, which is already signed in as the person,
201
- * and push the code to it. Returns owner/name, or null when skipped or failed.
202
- * DocuGate's own GitHub sign-in is not given the right to create repositories.
203
- */
204
- async function createGitHubRepo(root, io) {
205
- const pick = await choose(io, 'This project is not on GitHub yet, and a space reads from a GitHub repository.', [
206
- 'Create a GitHub repository for it now (uses the GitHub CLI)',
207
- 'Skip for now',
208
- ]);
209
- if (pick !== 1)
210
- return null;
211
- if (!run('gh', ['auth', 'status'], root).ok) {
212
- io.print(' The GitHub CLI is not installed or not signed in.');
213
- io.print(' Install it from https://cli.github.com, run gh auth login, then docugate init again.');
214
- return null;
215
- }
216
- if (!run('git', ['rev-parse', 'HEAD'], root).ok) {
217
- io.print(' Commit your code first (git add -A, then git commit), then run docugate init again.');
218
- return null;
219
- }
220
- const visibility = await choose(io, 'Who can see the repository?', [
221
- 'Public',
222
- 'Private (a space from a private repository needs DocuGate Pro)',
223
- ]);
224
- const name = basename(root).replace(/[^A-Za-z0-9._-]+/g, '-');
225
- const made = run('gh', ['repo', 'create', name, visibility === 1 ? '--public' : '--private', '--source', '.', '--remote', 'origin', '--push'], root);
226
- if (!made.ok) {
227
- io.print(` Could not create the repository: ${made.out.split(/\r?\n/).pop()}`);
228
- return null;
229
- }
230
- const slug = defaultGetRemote(root);
231
- if (slug)
232
- io.print(` Created https://github.com/${slug} and pushed your code.`);
233
- return slug;
234
- }
235
285
  function defaultGetRemote(root) {
236
286
  try {
237
287
  const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
@@ -260,6 +310,9 @@ export async function initFlow(root, options = {}) {
260
310
  // One project gets one setup and one tour. Run inside frontend/ of a
261
311
  // project already set up at its root, and init points there instead of
262
312
  // starting a second one.
313
+ if (io.isTTY)
314
+ for (const line of banner())
315
+ io.print(line);
263
316
  const parent = setUpAbove(root);
264
317
  if (parent) {
265
318
  io.print(` This project is already set up in ${parent}.`);
@@ -366,93 +419,76 @@ export async function initFlow(root, options = {}) {
366
419
  }
367
420
  }
368
421
  }
369
- // ── 3b: connect this repository to a space ────────────────────────────────
422
+ // ── 3b: the space that holds this project's documentation ─────────────────
423
+ // A space reads markdown from a GitHub repository. A new space gets its own
424
+ // documentation repository, created on the person's account; an existing
425
+ // space can be picked instead. Either way docugate.json remembers it, so the
426
+ // pill can link to the published docs.
370
427
  if (signedIn) {
371
- // Read git remote; a project not on GitHub yet can be put there first.
372
- let repoSlug = getRemoteFn(root);
373
- if (!repoSlug && !yes) {
374
- repoSlug = await (options.createRepoFn ?? createGitHubRepo)(root, io);
428
+ const repoSlug = getRemoteFn(root);
429
+ let spaces = [];
430
+ try {
431
+ spaces = (await getSpacesFn()).spaces;
375
432
  }
376
- if (!repoSlug) {
377
- io.print(` A space reads from a GitHub repository, so this one can't be connected yet.`);
433
+ catch (err) {
434
+ connectError = `Couldn't load spaces: ${err.message}`;
435
+ io.print(` warning ${connectError}`);
378
436
  }
379
- else {
380
- // List spaces.
381
- let spaces = [];
437
+ const { docsDir: repoDocsDir, config } = loadConfig(root);
438
+ const projectName = config.title ?? repoSlug?.split('/')[1] ?? basename(root);
439
+ const hasRepo = (sp) => Boolean(repoSlug) && (sp.sources ?? []).some((src) => src.repo.toLowerCase() === repoSlug.toLowerCase());
440
+ const already = spaces.find(hasRepo);
441
+ const linkSpace = (sp) => {
442
+ saveSetting(root, 'space', `${sp.owner}/${sp.slug}`);
443
+ io.print(` Docs will be published at ${BASE_URL}/${sp.owner}/${sp.slug}`);
444
+ };
445
+ const connectProject = async (sp) => {
446
+ if (!repoSlug || hasRepo(sp))
447
+ return;
382
448
  try {
383
- const res = await getSpacesFn();
384
- spaces = res.spaces;
449
+ await connectFn(sp.id, repoSlug, repoDocsDir);
450
+ io.print(` Connected ${repoSlug} to space "${sp.name}".`);
385
451
  }
386
452
  catch (err) {
387
- connectError = `Couldn't load spaces: ${err.message}`;
453
+ connectError = err.message;
388
454
  io.print(` warning ${connectError}`);
389
455
  }
390
- const { docsDir: repoDocsDir } = loadConfig(root);
391
- const hasRepo = (sp) => (sp.sources ?? []).some((src) => src.repo.toLowerCase() === repoSlug.toLowerCase());
392
- const already = spaces.find(hasRepo);
393
- if (connectError) {
394
- // the warning is printed already; carry on with the rest of setup
456
+ };
457
+ if (connectError) {
458
+ // the warning is printed already; carry on with the rest of setup
459
+ }
460
+ else if (yes) {
461
+ // --yes: use the space only when there is no doubt which one is meant.
462
+ const only = already ?? (spaces.length === 1 ? spaces[0] : undefined);
463
+ if (only) {
464
+ await connectProject(only);
465
+ linkSpace(only);
395
466
  }
396
- else if (yes) {
397
- // --yes: connect only when there is no doubt which space is meant.
398
- if (already) {
399
- io.print(` ${repoSlug} is already in space "${already.name}".`);
400
- }
401
- else if (spaces.length === 1) {
402
- try {
403
- await connectFn(spaces[0].id, repoSlug, repoDocsDir);
404
- io.print(` Connected ${repoSlug} to space "${spaces[0].name}".`);
405
- }
406
- catch (err) {
407
- connectError = err.message;
408
- io.print(` warning ${connectError}`);
409
- }
410
- }
411
- else if (spaces.length > 1) {
412
- io.print(` Several spaces found: run docugate init again without --yes to choose.`);
413
- }
467
+ else if (spaces.length > 1) {
468
+ io.print(` Several spaces found: run docugate init again without --yes to choose.`);
469
+ }
470
+ }
471
+ else {
472
+ const choices = [
473
+ 'Create a new space, with a new repository for its docs',
474
+ ...spaces.map((sp) => `${sp.name} (${sp.owner}/${sp.slug})${hasRepo(sp) ? ' already has this project' : ''}`),
475
+ 'Skip for now',
476
+ ];
477
+ const pick = await choose(io, `Where should the documentation for ${projectName} live?`, choices, already ? spaces.indexOf(already) + 2 : 1);
478
+ if (pick === 1) {
479
+ const created = await createDocsSpace(io, projectName, BASE_URL, options);
480
+ if (created)
481
+ linkSpace(created);
482
+ else
483
+ connectError = 'The space was not created.';
484
+ }
485
+ else if (pick <= spaces.length + 1) {
486
+ const sp = spaces[pick - 2];
487
+ await connectProject(sp);
488
+ linkSpace(sp);
414
489
  }
415
490
  else {
416
- // Interactive: create a space, or pick one, or skip. Numbers only.
417
- const choices = [
418
- `Create a new space for ${repoSlug}`,
419
- ...spaces.map((sp) => `${sp.name} (${sp.owner}/${sp.slug})${hasRepo(sp) ? ' already reads this repository' : ''}`),
420
- 'Skip for now',
421
- ];
422
- const pick = await choose(io, `Which DocuGate space should document ${repoSlug}?`, choices, already ? spaces.indexOf(already) + 2 : 1);
423
- if (pick === 1) {
424
- try {
425
- const createFn = options.createSpaceFn ?? (async (repo, name, dir) => {
426
- const { createSpace } = await import('./api.js');
427
- return createSpace(repo, name, dir);
428
- });
429
- const created = await createFn(repoSlug, loadConfig(root).config.title ?? repoSlug.split('/')[1], repoDocsDir);
430
- io.print(` Created space "${created.name}": ${BASE_URL}/${created.owner}/${created.slug}`);
431
- }
432
- catch (err) {
433
- connectError = err.message;
434
- io.print(` warning ${connectError}`);
435
- }
436
- }
437
- else if (pick <= spaces.length + 1) {
438
- const sp = spaces[pick - 2];
439
- if (hasRepo(sp)) {
440
- io.print(` ${repoSlug} is already in space "${sp.name}". Nothing to change.`);
441
- }
442
- else {
443
- try {
444
- await connectFn(sp.id, repoSlug, repoDocsDir);
445
- io.print(` Connected ${repoSlug} to space "${sp.name}".`);
446
- }
447
- catch (err) {
448
- connectError = err.message;
449
- io.print(` warning ${connectError}`);
450
- }
451
- }
452
- }
453
- else {
454
- io.print(` Skipped. docugate init connects it any time.`);
455
- }
491
+ io.print(` Skipped. docugate init sets it up any time.`);
456
492
  }
457
493
  }
458
494
  }
@@ -477,29 +513,60 @@ export async function initFlow(root, options = {}) {
477
513
  'IBM Bob (uses Bobcoins)',
478
514
  'IBM watsonx (coming soon)',
479
515
  'Skip, I will write it in the browser',
480
- ], 3);
516
+ ]);
481
517
  if (pick === 2)
482
518
  io.print(' IBM watsonx is coming soon. Skipped for now: run docugate tour init when you are ready.');
483
519
  runTour = pick === 1;
484
- if (runTour && !readBobKey() && !options.runBob)
485
- runTour = await askForBobKey(io, options.openUrl);
520
+ if (runTour && !readBobKey() && !options.runBob) {
521
+ // The key may be in the environment under another name.
522
+ const [other] = bobKeyCandidates();
523
+ if (other) {
524
+ const use = await choose(io, `BOB_API_KEY is not set, but ${other} is. Is that your IBM Bob key?`, [
525
+ `Yes, use ${other} for this run`,
526
+ 'No',
527
+ ]);
528
+ if (use === 1) {
529
+ useBobKeyForThisRun(process.env[other]);
530
+ io.print(` Tip: rename it to BOB_API_KEY in your environment so Bob finds it next time.`);
531
+ }
532
+ }
533
+ if (!readBobKey())
534
+ runTour = await askForBobKey(io, options.openUrl);
535
+ }
486
536
  }
487
537
  if (runTour) {
538
+ // Bob takes a minute or two. Show it working, the way an assistant does.
539
+ const stop = options.runBob
540
+ ? () => undefined
541
+ : startSpinner([
542
+ 'IBM Bob is reading your screens and routes',
543
+ 'Following each value to the endpoint it comes from',
544
+ 'Tracing it into the backend',
545
+ 'Writing the tour, one step per element',
546
+ 'Checking every file it names exists',
547
+ 'Still working: bigger apps take a little longer',
548
+ ]);
488
549
  try {
489
550
  tourResult = tourInit(root, { runBob: options.runBob });
551
+ stop();
552
+ io.print(` IBM Bob wrote the tour.`);
490
553
  }
491
554
  catch (error) {
555
+ stop();
492
556
  // Bob missing or failed — describe what went wrong, then continue.
493
557
  if (error instanceof BobKeyMissingError) {
494
558
  tourError =
495
- `Bob Shell needs an API key. Set BOB_API_KEY, then run docugate tour init.`;
559
+ `IBM Bob did not accept the key. Check BOB_API_KEY holds an Inference key from bob.ibm.com, then run docugate tour init.`;
496
560
  }
497
561
  else if (error instanceof BobMissingError) {
498
562
  tourError =
499
563
  `Bob Shell is not installed. Install it, sign in, then run docugate tour init.`;
500
564
  }
501
565
  else {
502
- 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}`);
503
570
  }
504
571
  io.print(` warning ${tourError}`);
505
572
  }
@@ -545,7 +612,35 @@ export async function initFlow(root, options = {}) {
545
612
  io.print(` installed pill in ${installResult.target}`);
546
613
  if (installResult?.action === 'already')
547
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
+ });
548
633
  io.print(``);
549
- 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
+ }
550
645
  return { ...base, role, tourResult, tourError, installResult, connectError };
551
646
  }
package/dist/rules.js CHANGED
@@ -58,4 +58,4 @@ export function firstHeading(content) {
58
58
  const heading = content.match(/^#\s+(.+)$/m);
59
59
  return heading ? heading[1].trim() : null;
60
60
  }
61
- export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'role', 'freshness', 'sidebar', 'api']);
61
+ export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'role', 'freshness', 'space', 'sidebar', 'api']);
@@ -0,0 +1,49 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * A live "working on it" line for the waits in setup: a spinner, the elapsed
4
+ * time, and a status that changes every few seconds, so a minute of IBM Bob
5
+ * reading the code never looks like a frozen terminal.
6
+ *
7
+ * It runs in a small child process, so it keeps moving even while the main
8
+ * process is blocked waiting on Bob Shell. Only in a terminal; elsewhere it
9
+ * prints the first status once.
10
+ */
11
+ export function startSpinner(statuses) {
12
+ if (!process.stderr.isTTY || process.env.CI) {
13
+ process.stderr.write(` ${statuses[0]}...\n`);
14
+ return (finalLine) => {
15
+ if (finalLine)
16
+ process.stderr.write(`${finalLine}\n`);
17
+ };
18
+ }
19
+ const script = `
20
+ const statuses = JSON.parse(process.argv[1]);
21
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
22
+ const start = Date.now();
23
+ let i = 0;
24
+ const cols = () => process.stderr.columns || 80;
25
+ setInterval(() => {
26
+ const secs = Math.floor((Date.now() - start) / 1000);
27
+ const status = statuses[Math.min(Math.floor(secs / 6), statuses.length - 1)];
28
+ const line = ' ' + frames[i++ % frames.length] + ' ' + status + '... ' + secs + 's';
29
+ process.stderr.write('\\r' + line.slice(0, cols() - 1).padEnd(cols() - 1));
30
+ }, 90);
31
+ `;
32
+ const child = spawn(process.execPath, ['-e', script, JSON.stringify(statuses)], {
33
+ stdio: ['ignore', 'ignore', 'inherit'],
34
+ });
35
+ return (finalLine) => {
36
+ child.kill();
37
+ const width = process.stderr.columns || 80;
38
+ process.stderr.write(`\r${' '.repeat(width - 1)}\r`);
39
+ if (finalLine)
40
+ process.stderr.write(`${finalLine}\n`);
41
+ };
42
+ }
43
+ /** Environment variables that look like an IBM Bob key under another name. */
44
+ export function bobKeyCandidates(env = process.env) {
45
+ return Object.keys(env)
46
+ .filter((name) => name !== 'BOB_API_KEY' && env[name])
47
+ .filter((name) => /BOB|IBM|WATSONX/i.test(name) && /KEY|TOKEN|SECRET/i.test(name))
48
+ .sort();
49
+ }
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
  }
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, watch,
4
4
  import { join, relative, resolve, isAbsolute } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { loadTours, matchRoute, parseTourFile, routeToFilename, serializeTourFile } from './tour.js';
7
+ import { loadConfig } from './config.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // Security helpers
9
10
  // ---------------------------------------------------------------------------
@@ -91,11 +92,19 @@ function handleGetTour(req, res, root) {
91
92
  const pathname = url.searchParams.get('path') ?? '/';
92
93
  const tours = loadTours(root);
93
94
  const match = tours.find((t) => matchRoute(t.route, pathname));
95
+ const docsUrl = publishedDocsUrl(root);
94
96
  if (!match) {
95
- sendJson(res, 404, { error: 'not found' });
97
+ sendJson(res, 404, { error: 'not found', docsUrl });
96
98
  return;
97
99
  }
98
- sendJson(res, 200, { route: match.route, title: match.title, file: match.file, stops: match.stops });
100
+ sendJson(res, 200, { route: match.route, title: match.title, file: match.file, docsUrl, stops: match.stops });
101
+ }
102
+ /** Where this project's docs are published, from the space docugate init saved. */
103
+ export function publishedDocsUrl(root) {
104
+ const space = loadConfig(root).config.space;
105
+ if (!space || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(space))
106
+ return undefined;
107
+ return `${process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site'}/${space}`;
99
108
  }
100
109
  function handleGetEvents(req, res, root) {
101
110
  const tourDir = join(root, '.docugate', 'tour');
@@ -366,6 +375,19 @@ export function startTourServer(root, port) {
366
375
  else if (req.method === 'GET' && path === '/pill.js') {
367
376
  handleGetPill(res);
368
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
+ }
369
391
  else {
370
392
  sendText(res, 404, 'Not found');
371
393
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docugate",
3
- "version": "0.2.5",
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": {
package/pill/dist/pill.js CHANGED
@@ -104,11 +104,14 @@ async function fetchTour(base) {
104
104
  }
105
105
  try {
106
106
  const res = await fetch(`${base}/tour?path=${encodeURIComponent(pathname)}`, { signal: AbortSignal.timeout(3000) });
107
- if (res.status === 404)
108
- return { alive: true, tour: null };
107
+ if (res.status === 404) {
108
+ const body = (await res.json().catch(() => ({})));
109
+ return { alive: true, tour: null, docsUrl: body.docsUrl };
110
+ }
109
111
  if (!res.ok)
110
112
  return { alive: false, tour: null };
111
- return { alive: true, tour: (await res.json()) };
113
+ const tour = (await res.json());
114
+ return { alive: true, tour, docsUrl: tour.docsUrl };
112
115
  }
113
116
  catch {
114
117
  return { alive: false, tour: null };
@@ -247,6 +250,8 @@ kbd {
247
250
  font-size: 12px;
248
251
  color: #8f8f8f;
249
252
  }
253
+ .docs-link { margin-left: auto; color: #F4C43F; text-decoration: none; }
254
+ .docs-link:hover { color: #ffd35c; background: rgba(244, 196, 63, 0.1); }
250
255
  .menu-foot .live { display: inline-flex; align-items: center; gap: 6px; }
251
256
  .menu-foot .live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #45d483; box-shadow: 0 0 0 3px rgba(69, 212, 131, 0.15); }
252
257
  .link-btn { border: 0; background: none; color: #a1a1a1; font: inherit; cursor: pointer; padding: 2px 4px; border-radius: 5px; }
@@ -601,6 +606,8 @@ class DocugatePill {
601
606
  this.listCollapsed = false;
602
607
  /** Hidden for this page view only: a reload, or Alt+Shift+D, brings it back. */
603
608
  this.hidden = false;
609
+ /** The project's published docs on DocuGate, when init linked a space. */
610
+ this.docsUrl = null;
604
611
  this.retarget = null;
605
612
  /** When a re-attached step is saved, the target it had before. */
606
613
  this.editingFrom = null;
@@ -640,9 +647,10 @@ class DocugatePill {
640
647
  this.render();
641
648
  }
642
649
  async load() {
643
- const { alive, tour } = await fetchTour(this.base);
650
+ const { alive, tour, docsUrl } = await fetchTour(this.base);
644
651
  this.alive = alive;
645
652
  this.tour = tour;
653
+ this.docsUrl = docsUrl ?? null;
646
654
  }
647
655
  render() {
648
656
  this.removePill();
@@ -921,7 +929,17 @@ class DocugatePill {
921
929
  hide.textContent = 'Hide';
922
930
  hide.title = 'Hide the pill. Reload the page or press Alt+Shift+D to bring it back.';
923
931
  hide.addEventListener('click', () => this.setHidden(true));
924
- foot.append(status, hide);
932
+ foot.append(status);
933
+ if (this.docsUrl && /^https:\/\//.test(this.docsUrl)) {
934
+ const docs = document.createElement('a');
935
+ docs.className = 'link-btn docs-link';
936
+ docs.href = this.docsUrl;
937
+ docs.target = '_blank';
938
+ docs.rel = 'noopener noreferrer';
939
+ docs.textContent = 'Open the docs ↗';
940
+ foot.append(docs);
941
+ }
942
+ foot.append(hide);
925
943
  return foot;
926
944
  }
927
945
  /** Steps seen on this screen in this tab, by target, so the checklist shows progress. */