docugate 0.1.3 → 0.2.0

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,38 +1,67 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { check } from './check.js';
4
4
  import { loadConfig } from './config.js';
5
- import { init } from './init.js';
5
+ import { initFlow } from './init.js';
6
6
  import { buildOpenApi, isCurrent, writeOpenApi } from './openapi.js';
7
+ import { BobKeyMissingError, BobMissingError, tourInit } from './tour-init.js';
8
+ import { tourInstall, hasPillSnippet, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
9
+ import { loadTours } from './tour.js';
10
+ import { startTourServer } from './tour-server.js';
11
+ import { login, clearCredentials, loadCredentials } from './auth.js';
12
+ import { BASE_URL, getSession } from './api.js';
7
13
  const SITE = 'https://www.trydocugate.site';
8
- const HELP = `docugate: set up and check the docs DocuGate reads from your repository
9
-
10
- Usage
11
- docugate <command> [options]
12
-
13
- Commands
14
- init Set up this repository for DocuGate
15
- check Check the docs the way DocuGate will read them
16
- openapi Prepare an OpenAPI spec for DocuGate's API reference
17
-
18
- init
19
- --dir <folder> Docs folder (default: docs)
20
- --title <text> Space title (default: this folder's name)
21
-
22
- check
23
- --dir <folder> Check this folder instead of docugate.json's docsDir
24
- --strict Treat warnings as errors
25
-
26
- openapi
27
- --from <file|url> The spec to read (or api.generate.from in docugate.json)
28
- --out <file> Where to write it (default: openapi.json)
29
- --include <glob> Keep only paths that match; repeatable
30
- --exclude <glob> Drop paths that match; repeatable
31
- --redact <name> Remove a property from every schema and example; repeatable
32
- --check Fail if the file is out of date, instead of writing it
33
-
34
- Run it from the root of your repository.
35
- ${SITE}
14
+ const HELP = `docugate: set up and check the docs DocuGate reads from your repository
15
+
16
+ Usage
17
+ docugate <command> [options]
18
+
19
+ Commands
20
+ init Set up this repository for DocuGate (interactive)
21
+ login Sign in to DocuGate with GitHub
22
+ logout Sign out and delete saved credentials
23
+ whoami Print the currently signed-in user
24
+ check Check the docs the way DocuGate will read them
25
+ openapi Prepare an OpenAPI spec for DocuGate's API reference
26
+ tour init Write the first tour of this app with IBM Bob
27
+ tour install Add the pill loader to your app
28
+ tour serve Start a local server that serves tour data for the pill
29
+ tour export Write every tour screen to a single JSON file
30
+
31
+ init
32
+ --dir <folder> Docs folder (default: docs)
33
+ --title <text> Space title (default: this folder's name)
34
+ --role <role> Repository role: frontend, backend, or both
35
+ --yes Accept every default without asking
36
+
37
+ check
38
+ --dir <folder> Check this folder instead of docugate.json's docsDir
39
+ --strict Treat warnings as errors
40
+
41
+ openapi
42
+ --from <file|url> The spec to read (or api.generate.from in docugate.json)
43
+ --out <file> Where to write it (default: openapi.json)
44
+ --include <glob> Keep only paths that match; repeatable
45
+ --exclude <glob> Drop paths that match; repeatable
46
+ --redact <name> Remove a property from every schema and example; repeatable
47
+ --check Fail if the file is out of date, instead of writing it
48
+
49
+ tour init
50
+ --max-cost <n> Most Bobcoins the run may spend (default: 3)
51
+ --team-id <id> Bob team to run under, for API keys that need one
52
+
53
+ tour install
54
+ --port <n> Port the pill server listens on (default: 4178)
55
+ --remove Remove the pill snippet instead of adding it
56
+
57
+ tour serve
58
+ --port <n> Port to listen on (default: 4178)
59
+
60
+ tour export <file>
61
+ Write all tour screens to <file> as JSON.
62
+
63
+ Run it from the root of your repository.
64
+ ${SITE}
36
65
  `;
37
66
  const color = process.stdout.isTTY && !process.env.NO_COLOR;
38
67
  const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
@@ -40,11 +69,13 @@ const red = (t) => paint(31, t);
40
69
  const yellow = (t) => paint(33, t);
41
70
  const green = (t) => paint(32, t);
42
71
  const dim = (t) => paint(2, t);
43
- const VALUE_FLAGS = new Set(['dir', 'title', 'from', 'out', 'include', 'exclude', 'redact']);
72
+ const VALUE_FLAGS = new Set(['dir', 'title', 'role', 'from', 'out', 'include', 'exclude', 'redact', 'max-cost', 'team-id', 'port']);
44
73
  function parse(argv) {
45
74
  const flags = new Map();
46
75
  const bools = new Set();
47
76
  let command;
77
+ let sub;
78
+ let sub2;
48
79
  for (let i = 0; i < argv.length; i++) {
49
80
  const arg = argv[i];
50
81
  if (arg === '-h')
@@ -64,10 +95,14 @@ function parse(argv) {
64
95
  }
65
96
  else if (!command)
66
97
  command = arg;
98
+ else if (command === 'tour' && !sub)
99
+ sub = arg;
100
+ else if (command === 'tour' && sub === 'export' && !sub2)
101
+ sub2 = arg;
67
102
  else
68
103
  fail(`Unexpected argument "${arg}".`);
69
104
  }
70
- return { command, flags, bools };
105
+ return { command, sub, sub2, flags, bools };
71
106
  }
72
107
  function fail(message) {
73
108
  // Phrased without a command name on purpose: this is the message somebody
@@ -97,14 +132,78 @@ async function main() {
97
132
  if (args.bools.has('help') || !args.command)
98
133
  return console.log(HELP);
99
134
  switch (args.command) {
135
+ case 'login': {
136
+ const creds = loadCredentials();
137
+ if (creds) {
138
+ // Already signed in — confirm with the server.
139
+ try {
140
+ const { user } = await getSession();
141
+ if (user) {
142
+ console.log(`Already signed in as @${user.githubLogin}`);
143
+ return;
144
+ }
145
+ }
146
+ catch {
147
+ // server unreachable; proceed to sign in again
148
+ }
149
+ }
150
+ const { token } = await login(BASE_URL, args.bools.has('yes') ? {} : {});
151
+ try {
152
+ const { user } = await getSession();
153
+ if (user) {
154
+ console.log(`Signed in as @${user.githubLogin}`);
155
+ }
156
+ else {
157
+ console.log('Signed in.');
158
+ }
159
+ }
160
+ catch {
161
+ // token saved; just confirm
162
+ console.log('Signed in.');
163
+ }
164
+ void token;
165
+ return;
166
+ }
167
+ case 'logout': {
168
+ clearCredentials();
169
+ console.log('Signed out.');
170
+ return;
171
+ }
172
+ case 'whoami': {
173
+ const creds = loadCredentials();
174
+ if (!creds) {
175
+ console.log('Not signed in. Run docugate login.');
176
+ return;
177
+ }
178
+ let session;
179
+ try {
180
+ session = await getSession();
181
+ }
182
+ catch (err) {
183
+ console.error(`Signed in, but couldn't reach DocuGate to confirm who you are: ${err.message}`);
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ if (session.user) {
188
+ console.log(`@${session.user.githubLogin}`);
189
+ }
190
+ else {
191
+ console.log('Not signed in. Run docugate login.');
192
+ }
193
+ return;
194
+ }
100
195
  case 'init': {
101
- const result = init(root, { dir: one('dir'), title: one('title') });
102
- for (const f of result.created)
103
- console.log(` ${green('created')} ${f}`);
104
- for (const f of result.kept)
105
- console.log(` ${dim('kept')} ${f}`);
106
- console.log(`\nNext: commit and push, then publish the space at ${SITE}/dashboard/new` +
107
- `\nand pick "${result.docsDir}" as the docs folder.`);
196
+ const roleFlag = one('role');
197
+ if (roleFlag && !['frontend', 'backend', 'both'].includes(roleFlag)) {
198
+ fail(`--role must be frontend, backend, or both.`);
199
+ }
200
+ await initFlow(root, {
201
+ dir: one('dir'),
202
+ title: one('title'),
203
+ role: roleFlag,
204
+ yes: args.bools.has('yes'),
205
+ port: one('port') !== undefined ? parseInt(one('port'), 10) : undefined,
206
+ });
108
207
  return;
109
208
  }
110
209
  case 'check': {
@@ -153,6 +252,117 @@ async function main() {
153
252
  console.log(`${green('wrote')} ${output} ${dim(`(${summary})`)}`);
154
253
  return;
155
254
  }
255
+ case 'tour': {
256
+ switch (args.sub) {
257
+ case 'install': {
258
+ const port = parseInt(one('port') ?? String(DEFAULT_PORT), 10);
259
+ const remove = args.bools.has('remove');
260
+ const result = tourInstall(root, { port, remove });
261
+ if (result.target === null) {
262
+ console.log(` ${yellow('not found')} No supported HTML or layout file found.`);
263
+ console.log(` Paste this into your app's HTML during development:`);
264
+ console.log(` ${pillSnippetLine(port)}`);
265
+ }
266
+ else if (result.action === 'inserted') {
267
+ console.log(` ${green('installed')} ${result.target}`);
268
+ }
269
+ else if (result.action === 'removed') {
270
+ console.log(` ${green('removed')} ${result.target}`);
271
+ }
272
+ else {
273
+ // already / no-op
274
+ console.log(` ${dim('unchanged')} ${result.target}`);
275
+ }
276
+ return;
277
+ }
278
+ case 'init': {
279
+ console.log(`${dim('Bob is reading your code and writing the tour. This takes a minute or two.')}`);
280
+ let result;
281
+ try {
282
+ result = tourInit(root, { maxCost: one('max-cost'), teamId: one('team-id') });
283
+ }
284
+ catch (error) {
285
+ if (error instanceof BobKeyMissingError) {
286
+ console.error(`${red('error')} Bob Shell needs an API key to run on its own (your IDE sign-in is not used here).
287
+ ` +
288
+ `1. At https://bob.ibm.com, open your instance, then API keys, and create an Inference key.
289
+ ` +
290
+ `2. Save it as the BOB_API_KEY environment variable, then open a new terminal:
291
+ ` +
292
+ ` PowerShell [Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User")
293
+ ` +
294
+ ` macOS/Linux export BOB_API_KEY=<key> (add it to your shell profile)
295
+ ` +
296
+ dim('Keep the key out of your repository. https://bob.ibm.com/docs/ide/account/api-keys'));
297
+ process.exit(2);
298
+ }
299
+ if (!(error instanceof BobMissingError))
300
+ throw error;
301
+ console.error(`${red('error')} Bob Shell is not installed, and tours are written by IBM Bob.
302
+ ` +
303
+ `Install it (Node 24 or later), sign in, then run this again:
304
+ ` +
305
+ ` Windows powershell -c "irm -Uri https://bob.ibm.com/download/bobshell.ps1 | iex"
306
+ ` +
307
+ ` macOS/Linux curl -fsSL https://bob.ibm.com/download/bobshell.sh | bash
308
+ ` +
309
+ dim('https://bob.ibm.com/docs/shell/getting-started/install-and-setup'));
310
+ process.exit(2);
311
+ }
312
+ for (const f of result.setup)
313
+ console.log(` ${green('created')} ${f}`);
314
+ for (const f of result.added)
315
+ console.log(` ${green('added')} ${f}`);
316
+ for (const f of result.restored)
317
+ console.log(` ${yellow('kept')} ${f} ${dim('(Bob changed it; your version was put back)')}`);
318
+ if (!result.added.length)
319
+ console.log(` ${dim('No new screens: every screen Bob found already has a tour file.')}`);
320
+ const stats = result.stats;
321
+ const parts = [
322
+ result.mode === 'agent' ? 'agent mode' : 'Tour Writer mode',
323
+ stats?.durationMs !== undefined ? `${Math.round(stats.durationMs / 1000)}s` : '',
324
+ stats?.cost !== undefined ? `${stats.cost} Bobcoins` : '',
325
+ ].filter(Boolean);
326
+ console.log(`
327
+ ${dim(`Bob: ${parts.join(', ')}`)}`);
328
+ console.log('Next: read the new files, fix anything Bob got wrong, and commit them. They are yours now.');
329
+ return;
330
+ }
331
+ case 'serve': {
332
+ const tourDir = `${root}/.docugate/tour`;
333
+ if (!existsSync(tourDir)) {
334
+ console.error(`${red('error')} .docugate/tour/ not found. Run ${green('docugate tour init')} first.`);
335
+ process.exit(2);
336
+ }
337
+ const port = parseInt(one('port') ?? '4178', 10);
338
+ const server = await startTourServer(root, port);
339
+ const url = `http://localhost:${server.port}`;
340
+ const screens = loadTours(root);
341
+ console.log(`${green('Tour server')} ${url}`);
342
+ console.log(`${plural(screens.length, 'screen')} loaded from .docugate/tour/`);
343
+ if (!hasPillSnippet(root)) {
344
+ console.log(`${dim('Hint: run')} ${green('docugate tour install')} ${dim('to add the pill to your app automatically.')}`);
345
+ }
346
+ // Keep the process alive; Ctrl-C will stop it.
347
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
348
+ process.on('SIGTERM', () => { server.close(); process.exit(0); });
349
+ await new Promise(() => { });
350
+ return;
351
+ }
352
+ case 'export': {
353
+ const outFile = args.sub2;
354
+ if (!outFile)
355
+ fail('Say where to write: docugate tour export <file>.');
356
+ const screens = loadTours(root);
357
+ const json = JSON.stringify(screens.map(({ route, title, stops }) => ({ route, title, stops })), null, 2);
358
+ writeFileSync(outFile, json);
359
+ console.log(`${green('wrote')} ${outFile} ${dim(`(${plural(screens.length, 'screen')})`)}`);
360
+ return;
361
+ }
362
+ default:
363
+ fail(args.sub ? `Unknown tour command "${args.sub}".` : 'Say which: docugate tour init, tour install, or tour serve.');
364
+ }
365
+ }
156
366
  default:
157
367
  fail(`Unknown command "${args.command}".`);
158
368
  }
package/dist/init.js CHANGED
@@ -1,6 +1,11 @@
1
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
1
+ import { createInterface } from 'node:readline';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
4
  import { basename, join } from 'node:path';
3
5
  import { CONFIG_FILE, loadConfig } from './config.js';
6
+ import { tourInit, BobMissingError, BobKeyMissingError } from './tour-init.js';
7
+ import { tourInstall, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
8
+ import { login, loadCredentials } from './auth.js';
4
9
  /**
5
10
  * Sets a repository up for DocuGate: a docugate.json, and a first page if the
6
11
  * docs folder has none. Never overwrites — running it twice is harmless, and
@@ -34,3 +39,293 @@ export function init(root, opts = {}) {
34
39
  }
35
40
  return { created, kept, docsDir };
36
41
  }
42
+ function makeDefaultIO() {
43
+ const isTTY = Boolean(process.stdin.isTTY);
44
+ return {
45
+ isTTY,
46
+ ask(question, defaultAnswer) {
47
+ return new Promise((resolve) => {
48
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
49
+ rl.question(`${question} `, (answer) => {
50
+ rl.close();
51
+ resolve(answer.trim() || defaultAnswer);
52
+ });
53
+ });
54
+ },
55
+ print(line) {
56
+ console.log(line);
57
+ },
58
+ };
59
+ }
60
+ function saveRole(root, role) {
61
+ const path = join(root, CONFIG_FILE);
62
+ let existing = {};
63
+ if (existsSync(path)) {
64
+ try {
65
+ existing = JSON.parse(readFileSync(path, 'utf8'));
66
+ }
67
+ catch {
68
+ // leave as empty object; will be overwritten with role added
69
+ }
70
+ }
71
+ existing.role = role;
72
+ writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
73
+ }
74
+ const VALID_ROLES = new Set(['frontend', 'backend', 'both']);
75
+ function defaultGetRemote(root) {
76
+ try {
77
+ const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
78
+ encoding: 'utf8',
79
+ cwd: root,
80
+ stdio: ['ignore', 'pipe', 'ignore'],
81
+ }).trim();
82
+ // Match HTTPS: https://github.com/owner/repo(.git)
83
+ // Match SSH: git@github.com:owner/repo(.git)
84
+ const m = remote.match(/github\.com[:/]([^/]+\/[^/\s]+?)(?:\.git)?$/);
85
+ return m ? m[1] : null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /**
92
+ * Runs the full interactive DocuGate setup wizard. Asks one short question,
93
+ * saves the result in docugate.json, conditionally runs tour init and tour
94
+ * install, and prints a summary.
95
+ */
96
+ export async function initFlow(root, options = {}) {
97
+ const io = options.io ?? makeDefaultIO();
98
+ const yes = options.yes ?? false;
99
+ const port = options.port ?? DEFAULT_PORT;
100
+ // ── Step 1: base repo setup (docugate.json + docs/) ──────────────────────
101
+ const base = init(root, { dir: options.dir, title: options.title });
102
+ // ── Step 2: determine role ────────────────────────────────────────────────
103
+ const loaded = loadConfig(root);
104
+ const savedRole = loaded.config.role;
105
+ let role = savedRole ?? options.role;
106
+ if (!role) {
107
+ if (!io.isTTY) {
108
+ // CI / no terminal: write config without role and explain.
109
+ io.print(` docugate.json written. Set the repository role with --role frontend|backend|both.`);
110
+ io.print(` Run docugate login to connect this repository to a DocuGate space.`);
111
+ // Return early — no tour steps without a known role.
112
+ return { ...base, role: undefined };
113
+ }
114
+ if (yes) {
115
+ // --yes with no --role: default to "both"
116
+ role = 'both';
117
+ }
118
+ else {
119
+ // Interactive: ask once; re-prompt on invalid answer.
120
+ let answer = '';
121
+ while (!VALID_ROLES.has(answer)) {
122
+ answer = await io.ask('Is this repository the frontend, the backend, or both? [frontend/backend/both]', 'both');
123
+ if (!VALID_ROLES.has(answer)) {
124
+ io.print(` Please answer frontend, backend, or both.`);
125
+ }
126
+ }
127
+ role = answer;
128
+ }
129
+ }
130
+ if (!savedRole) {
131
+ saveRole(root, role);
132
+ }
133
+ // ── Step 3: sign in and connect this repository ───────────────────────────
134
+ const BASE_URL = options.baseUrl ?? process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site';
135
+ let connectError;
136
+ // In CI (no TTY) skip sign-in entirely (hint already printed in the early-return branch above).
137
+ if (!io.isTTY) {
138
+ // nothing more — hint was already printed
139
+ }
140
+ else {
141
+ // Lazily import real API functions only when not overridden by tests.
142
+ const realGetSession = async () => {
143
+ const { getSession } = await import('./api.js');
144
+ return getSession();
145
+ };
146
+ const realGetSpaces = async () => {
147
+ const { getSpaces } = await import('./api.js');
148
+ return getSpaces();
149
+ };
150
+ const realConnectRepo = async (id, repo, dir) => {
151
+ const { connectRepo } = await import('./api.js');
152
+ return connectRepo(id, repo, dir);
153
+ };
154
+ const realLogin = login;
155
+ const getSessionFn = options.getSessionFn ?? realGetSession;
156
+ const getSpacesFn = options.getSpacesFn ?? realGetSpaces;
157
+ const connectFn = options.connectFn ?? realConnectRepo;
158
+ const loginFn = options.loginFn ?? realLogin;
159
+ const getRemoteFn = options.getRemoteFn ?? defaultGetRemote;
160
+ // ── 3a: ensure the user is signed in ─────────────────────────────────────
161
+ let signedIn = false;
162
+ try {
163
+ // When getSessionFn is injected (tests), trust it directly.
164
+ // In production, only call it if there are saved credentials.
165
+ const creds = options.getSessionFn ? true : loadCredentials();
166
+ if (creds) {
167
+ const sess = await getSessionFn();
168
+ signedIn = sess.user !== null;
169
+ }
170
+ }
171
+ catch {
172
+ // can't reach server; treat as not signed in
173
+ }
174
+ if (!signedIn) {
175
+ let doLogin = yes;
176
+ if (!yes) {
177
+ const answer = await io.ask('Sign in to DocuGate now? [Y/n]', 'y');
178
+ doLogin = !/^n(o)?$/i.test(answer);
179
+ }
180
+ if (doLogin) {
181
+ try {
182
+ await loginFn(BASE_URL);
183
+ signedIn = true;
184
+ io.print(` Signed in to DocuGate.`);
185
+ }
186
+ catch (err) {
187
+ connectError = `Sign-in failed: ${err.message}`;
188
+ io.print(` warning ${connectError}`);
189
+ }
190
+ }
191
+ }
192
+ // ── 3b: connect this repository to a space ────────────────────────────────
193
+ if (signedIn) {
194
+ // Read git remote.
195
+ const repoSlug = getRemoteFn(root);
196
+ if (!repoSlug) {
197
+ io.print(` This repository has no GitHub remote yet, so it can't be connected to a space.`);
198
+ }
199
+ else {
200
+ // List spaces.
201
+ let spaces = [];
202
+ try {
203
+ const res = await getSpacesFn();
204
+ spaces = res.spaces;
205
+ }
206
+ catch (err) {
207
+ connectError = `Couldn't load spaces: ${err.message}`;
208
+ io.print(` warning ${connectError}`);
209
+ }
210
+ if (spaces.length === 0 && !connectError) {
211
+ io.print(` No spaces found. Create one first: ${BASE_URL}/dashboard/new`);
212
+ }
213
+ else if (spaces.length > 0) {
214
+ const { docsDir: repoDocsDir } = loadConfig(root);
215
+ if (yes) {
216
+ // --yes: auto-connect only when there is exactly one space.
217
+ if (spaces.length === 1) {
218
+ try {
219
+ await connectFn(spaces[0].id, repoSlug, repoDocsDir);
220
+ io.print(` Connected ${repoSlug} to space "${spaces[0].name}".`);
221
+ }
222
+ catch (err) {
223
+ connectError = err.message;
224
+ io.print(` warning ${connectError}`);
225
+ }
226
+ }
227
+ else {
228
+ io.print(` Multiple spaces found — run docugate init again without --yes to choose:`);
229
+ for (const s of spaces)
230
+ io.print(` ${s.owner}/${s.slug} ${s.name}`);
231
+ }
232
+ }
233
+ else {
234
+ // Interactive: list spaces and ask.
235
+ io.print(` Your spaces:`);
236
+ for (let i = 0; i < spaces.length; i++) {
237
+ io.print(` ${i + 1}. ${spaces[i].name} (${spaces[i].owner}/${spaces[i].slug})`);
238
+ }
239
+ const answer = await io.ask(`Connect ${repoSlug} to which space? [1-${spaces.length}/skip]`, 'skip');
240
+ const idx = parseInt(answer, 10) - 1;
241
+ if (!isNaN(idx) && idx >= 0 && idx < spaces.length) {
242
+ try {
243
+ await connectFn(spaces[idx].id, repoSlug, repoDocsDir);
244
+ io.print(` Connected ${repoSlug} to space "${spaces[idx].name}".`);
245
+ }
246
+ catch (err) {
247
+ connectError = err.message;
248
+ io.print(` warning ${connectError}`);
249
+ }
250
+ }
251
+ else {
252
+ io.print(` Skipped connecting to a space.`);
253
+ }
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ // ── Step 4: tour steps (frontend / both only) ─────────────────────────────
260
+ let tourResult;
261
+ let tourError;
262
+ let installResult;
263
+ if (role === 'frontend' || role === 'both') {
264
+ // Decide whether to run tour init.
265
+ let runTour = yes;
266
+ if (!yes && io.isTTY) {
267
+ const answer = await io.ask('Write the tour with IBM Bob? (costs Bobcoins) [y/N]', 'n');
268
+ runTour = /^y(es)?$/i.test(answer);
269
+ }
270
+ if (runTour) {
271
+ try {
272
+ tourResult = tourInit(root, { runBob: options.runBob });
273
+ }
274
+ catch (error) {
275
+ // Bob missing or failed — describe what went wrong, then continue.
276
+ if (error instanceof BobKeyMissingError) {
277
+ tourError =
278
+ `Bob Shell needs an API key. Set BOB_API_KEY, then run docugate tour init.`;
279
+ }
280
+ else if (error instanceof BobMissingError) {
281
+ tourError =
282
+ `Bob Shell is not installed. Install it, sign in, then run docugate tour init.`;
283
+ }
284
+ else {
285
+ tourError = `Tour init failed: ${error.message}. Run docugate tour init to retry.`;
286
+ }
287
+ io.print(` warning ${tourError}`);
288
+ }
289
+ }
290
+ // tour install: ask in TTY unless --yes.
291
+ let runInstall = yes;
292
+ if (!yes && io.isTTY) {
293
+ const answer = await io.ask('Add the pill loader to your app? [Y/n]', 'y');
294
+ runInstall = !/^n(o)?$/i.test(answer);
295
+ }
296
+ if (runInstall) {
297
+ installResult = tourInstall(root, { port });
298
+ if (installResult.target === null) {
299
+ io.print(` Paste this into your app's HTML during development:`);
300
+ io.print(` ${pillSnippetLine(port)}`);
301
+ }
302
+ }
303
+ }
304
+ else {
305
+ // backend
306
+ io.print(` backend role: skipping tour. The frontend repository's tour can point into this one.`);
307
+ }
308
+ // ── Step 5: summary ───────────────────────────────────────────────────────
309
+ io.print(``);
310
+ for (const f of base.created)
311
+ io.print(` created ${f}`);
312
+ for (const f of base.kept)
313
+ io.print(` kept ${f}`);
314
+ if (!savedRole && role)
315
+ io.print(` saved role: ${role} in ${CONFIG_FILE}`);
316
+ if (tourResult) {
317
+ for (const f of tourResult.setup)
318
+ io.print(` created ${f}`);
319
+ for (const f of tourResult.added)
320
+ io.print(` added ${f}`);
321
+ for (const f of tourResult.restored)
322
+ io.print(` kept ${f} (Bob changed it; your version was put back)`);
323
+ }
324
+ if (installResult?.action === 'inserted')
325
+ io.print(` installed pill in ${installResult.target}`);
326
+ if (installResult?.action === 'already')
327
+ io.print(` pill already in ${installResult.target}`);
328
+ io.print(``);
329
+ io.print(`Next: docugate tour serve`);
330
+ return { ...base, role, tourResult, tourError, installResult, connectError };
331
+ }
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', 'sidebar', 'api']);
61
+ export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'role', 'sidebar', 'api']);