docugate 0.2.8 → 0.3.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.
Files changed (82) hide show
  1. package/README.md +200 -57
  2. package/dist/api.d.ts +44 -0
  3. package/dist/api.js +11 -5
  4. package/dist/args.d.ts +29 -0
  5. package/dist/args.js +121 -0
  6. package/dist/auth.d.ts +21 -0
  7. package/dist/banner.d.ts +13 -0
  8. package/dist/banner.js +62 -0
  9. package/dist/check.d.ts +22 -0
  10. package/dist/check.js +4 -3
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +214 -165
  13. package/dist/commands.d.ts +35 -0
  14. package/dist/commands.js +293 -0
  15. package/dist/config.d.ts +22 -0
  16. package/dist/config.js +43 -3
  17. package/dist/developers.d.ts +35 -0
  18. package/dist/developers.js +439 -0
  19. package/dist/init.d.ts +130 -0
  20. package/dist/init.js +102 -36
  21. package/dist/keys/core.d.ts +108 -0
  22. package/dist/keys/core.js +142 -0
  23. package/dist/keys/express.d.ts +21 -0
  24. package/dist/keys/express.js +27 -0
  25. package/dist/keys/fastify.d.ts +21 -0
  26. package/dist/keys/fastify.js +21 -0
  27. package/dist/keys/format.d.ts +11 -0
  28. package/dist/keys/format.js +46 -0
  29. package/dist/keys/hono.d.ts +19 -0
  30. package/dist/keys/hono.js +21 -0
  31. package/dist/keys/index.d.ts +13 -0
  32. package/dist/keys/index.js +12 -0
  33. package/dist/logo-pixels.d.ts +4 -0
  34. package/dist/logo-pixels.js +35 -0
  35. package/dist/logo.d.ts +19 -0
  36. package/dist/logo.js +68 -0
  37. package/dist/menu.d.ts +16 -0
  38. package/dist/menu.js +157 -0
  39. package/dist/openapi.d.ts +38 -0
  40. package/dist/openapi.js +2 -1
  41. package/dist/rules.d.ts +69 -0
  42. package/dist/rules.js +2 -1
  43. package/dist/select.d.ts +75 -0
  44. package/dist/select.js +215 -0
  45. package/dist/session.d.ts +46 -0
  46. package/dist/session.js +54 -0
  47. package/dist/spinner.d.ts +12 -0
  48. package/dist/spinner.js +12 -12
  49. package/dist/status.d.ts +46 -0
  50. package/dist/status.js +166 -0
  51. package/dist/sync/ai.d.ts +44 -0
  52. package/dist/sync/ai.js +147 -0
  53. package/dist/sync/apply.d.ts +25 -0
  54. package/dist/sync/apply.js +104 -0
  55. package/dist/sync/changes.d.ts +58 -0
  56. package/dist/sync/changes.js +167 -0
  57. package/dist/sync/command.d.ts +12 -0
  58. package/dist/sync/command.js +236 -0
  59. package/dist/sync/guard.d.ts +15 -0
  60. package/dist/sync/guard.js +21 -0
  61. package/dist/sync/hook.d.ts +10 -0
  62. package/dist/sync/hook.js +62 -0
  63. package/dist/sync/impact.d.ts +23 -0
  64. package/dist/sync/impact.js +93 -0
  65. package/dist/sync/pr.d.ts +37 -0
  66. package/dist/sync/pr.js +106 -0
  67. package/dist/sync/run.d.ts +50 -0
  68. package/dist/sync/run.js +124 -0
  69. package/dist/sync/state.d.ts +8 -0
  70. package/dist/sync/state.js +22 -0
  71. package/dist/sync/workflow.d.ts +20 -0
  72. package/dist/sync/workflow.js +84 -0
  73. package/dist/text.d.ts +11 -0
  74. package/dist/text.js +16 -0
  75. package/dist/tour-init.d.ts +61 -0
  76. package/dist/tour-install.d.ts +38 -0
  77. package/dist/tour-install.js +58 -0
  78. package/dist/tour-server.d.ts +21 -0
  79. package/dist/tour.d.ts +60 -0
  80. package/dist/ui.d.ts +29 -0
  81. package/dist/ui.js +63 -0
  82. package/package.json +67 -47
@@ -0,0 +1,13 @@
1
+ export declare const TAGLINE = "Tours and docs for your codebase";
2
+ /** The wordmark as five plain lines, no color. */
3
+ export declare function wordmark(text?: string, gap?: number): string[];
4
+ export declare function packageVersion(): string;
5
+ export type BannerOptions = {
6
+ /** Terminal width; unknown is treated as narrow. */
7
+ columns?: number;
8
+ color?: boolean;
9
+ version?: string;
10
+ env?: NodeJS.ProcessEnv;
11
+ };
12
+ /** The banner as lines to print, with a blank line above and below. */
13
+ export declare function banner(options?: BannerOptions): string[];
package/dist/banner.js ADDED
@@ -0,0 +1,62 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { colorDepth, fg, logoBanner } from './logo.js';
3
+ import { colorOn, isMintty } from './ui.js';
4
+ // The banner: DOCUGATE in block letters, shaded from cream to gold like the
5
+ // owl, with the tagline and version under it. It adapts to where it is drawn:
6
+ // beside the owl in a wide terminal, alone in a normal one, as a single line
7
+ // in a narrow one, and without color when color is off. It is only ever
8
+ // printed to a real terminal, so logs and CI never see it.
9
+ export const TAGLINE = 'Tours and docs for your codebase';
10
+ const SITE = 'https://www.trydocugate.site';
11
+ // A five-row block font, just the letters in the name. Every glyph is five
12
+ // columns wide, so the wordmark's width is simply letters * 5 + gaps.
13
+ const GLYPHS = {
14
+ D: ['████ ', '█ █', '█ █', '█ █', '████ '],
15
+ O: [' ███ ', '█ █', '█ █', '█ █', ' ███ '],
16
+ C: [' ████', '█ ', '█ ', '█ ', ' ████'],
17
+ U: ['█ █', '█ █', '█ █', '█ █', ' ███ '],
18
+ G: [' ████', '█ ', '█ ██', '█ █', ' ████'],
19
+ A: [' ███ ', '█ █', '█████', '█ █', '█ █'],
20
+ T: ['█████', ' █ ', ' █ ', ' █ ', ' █ '],
21
+ E: ['█████', '█ ', '████ ', '█ ', '█████'],
22
+ };
23
+ // Cream at the top, through gold, to deep gold at the bottom.
24
+ const SHADES = [
25
+ [245, 240, 227],
26
+ [246, 222, 150],
27
+ [244, 196, 63],
28
+ [230, 175, 46],
29
+ [217, 154, 30],
30
+ ];
31
+ /** Narrower than this and the wordmark would wrap, so one line is shown. */
32
+ const WORDMARK_MIN_COLUMNS = 60;
33
+ /** Wide enough for the owl beside the wordmark. */
34
+ const OWL_MIN_COLUMNS = 96;
35
+ /** The wordmark as five plain lines, no color. */
36
+ export function wordmark(text = 'DOCUGATE', gap = 2) {
37
+ const letters = [...text.toUpperCase()].map((c) => GLYPHS[c]);
38
+ if (letters.some((g) => !g))
39
+ throw new Error(`The banner font has no glyph for every letter of "${text}".`);
40
+ return [0, 1, 2, 3, 4].map((row) => letters.map((g) => g[row]).join(' '.repeat(gap)).trimEnd());
41
+ }
42
+ export function packageVersion() {
43
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
44
+ return pkg.version;
45
+ }
46
+ /** The banner as lines to print, with a blank line above and below. */
47
+ export function banner(options = {}) {
48
+ const {
49
+ // mintty reports no width to a piped console; COLUMNS, or a usual window, stands in.
50
+ columns = process.stdout.columns ?? (Number(process.env.COLUMNS) || (isMintty() ? 100 : 0)), color = colorOn(), version = packageVersion(), env = process.env, } = options;
51
+ const dim = (t) => (color ? `\x1b[2m${t}\x1b[0m` : t);
52
+ if (columns < WORDMARK_MIN_COLUMNS) {
53
+ const full = ` DocuGate · ${TAGLINE} · v${version}`;
54
+ return ['', full.length <= columns ? full : ` DocuGate v${version}`, ''];
55
+ }
56
+ const depth = colorDepth(env);
57
+ const art = wordmark().map((line, row) => (color ? `${fg(SHADES[row], depth)}${line}\x1b[0m` : line));
58
+ const text = [...art, '', dim(TAGLINE), dim(`v${version} · ${SITE}`)];
59
+ if (color && columns >= OWL_MIN_COLUMNS)
60
+ return logoBanner(text, env);
61
+ return ['', ...text.map((t) => (t ? ` ${t}` : '')), ''];
62
+ }
@@ -0,0 +1,22 @@
1
+ import type { Issue } from './config.js';
2
+ export type CheckResult = {
3
+ docsDir: string;
4
+ pages: number;
5
+ errors: Issue[];
6
+ warnings: Issue[];
7
+ };
8
+ /** Every file under `dir`, as forward-slash paths relative to it. */
9
+ export declare function walk(dir: string): string[];
10
+ /**
11
+ * Checks a repository's docs the way DocuGate will read them, so problems turn
12
+ * up on the command line or in CI rather than on the published page.
13
+ *
14
+ * Errors are things DocuGate will get wrong or refuse. Warnings are things it
15
+ * handles, but probably not the way the author meant.
16
+ */
17
+ export declare function check(root: string, dirOverride?: string): CheckResult;
18
+ /**
19
+ * Run tour checks against every tour file found under `.docugate/tour/`.
20
+ * Issues are warnings: the docs still publish; the tour may just be wrong.
21
+ */
22
+ export declare function checkTours(root: string): Issue[];
package/dist/check.js CHANGED
@@ -2,11 +2,12 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import { loadConfig } from './config.js';
4
4
  import { MAX_FILES, firstHeading, landingPath, pathToSlug, resolveRelativeLink } from './rules.js';
5
+ import { readText } from './text.js';
5
6
  import { loadTours } from './tour.js';
6
7
  /** Never part of a repository on GitHub, and huge when present locally. */
7
8
  const SKIP = new Set(['node_modules', '.git']);
8
9
  /** Every file under `dir`, as forward-slash paths relative to it. */
9
- function walk(dir) {
10
+ export function walk(dir) {
10
11
  const out = [];
11
12
  const visit = (current) => {
12
13
  for (const entry of readdirSync(current, { withFileTypes: true })) {
@@ -95,7 +96,7 @@ export function check(root, dirOverride) {
95
96
  if (landing)
96
97
  slugs.add('');
97
98
  for (const page of pages) {
98
- const content = readFileSync(join(base, page), 'utf8').replace(/\r\n/g, '\n');
99
+ const content = readText(join(base, page)).replace(/\r\n/g, '\n');
99
100
  if (content.startsWith('---\n')) {
100
101
  warnings.push({
101
102
  file: shown(page),
@@ -183,7 +184,7 @@ function importsIn(text) {
183
184
  * Run tour checks against every tour file found under `.docugate/tour/`.
184
185
  * Issues are warnings: the docs still publish; the tour may just be wrong.
185
186
  */
186
- function checkTours(root) {
187
+ export function checkTours(root) {
187
188
  const warnings = [];
188
189
  let tours;
189
190
  try {
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js CHANGED
@@ -1,143 +1,170 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { parseArgs, UsageError } from './args.js';
7
+ import { banner, packageVersion } from './banner.js';
5
8
  import { check } from './check.js';
9
+ import { COMMANDS, findCommand, renderCommandHelp, renderCompletion, renderGroupHelp, renderHelp } from './commands.js';
6
10
  import { loadConfig } from './config.js';
7
- import { initFlow } from './init.js';
11
+ import { developersFlow } from './developers.js';
12
+ import { initFlow, makeDefaultIO } from './init.js';
13
+ import { runMenu } from './menu.js';
8
14
  import { buildOpenApi, isCurrent, writeOpenApi } from './openapi.js';
15
+ import { canPick } from './select.js';
16
+ import { gatherStatus, renderStatus } from './status.js';
17
+ import { syncCommand } from './sync/command.js';
9
18
  import { BobKeyMissingError, BobMissingError, tourInit } from './tour-init.js';
10
19
  import { tourInstall, hasPillSnippet, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
11
20
  import { loadTours } from './tour.js';
12
21
  import { startTourServer } from './tour-server.js';
13
22
  import { login, clearCredentials, loadCredentials } from './auth.js';
14
23
  import { BASE_URL, getSession } from './api.js';
24
+ import { dim, gold, green, hint, isMintty, plural, red, suggest, yellow } from './ui.js';
15
25
  const SITE = 'https://www.trydocugate.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}
67
- `;
68
- const color = process.stdout.isTTY && !process.env.NO_COLOR;
69
- const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
70
- const red = (t) => paint(31, t);
71
- const yellow = (t) => paint(33, t);
72
- const green = (t) => paint(32, t);
73
- const dim = (t) => paint(2, t);
74
- const VALUE_FLAGS = new Set(['dir', 'title', 'role', 'from', 'out', 'include', 'exclude', 'redact', 'max-cost', 'team-id', 'port']);
75
- function parse(argv) {
76
- const flags = new Map();
77
- const bools = new Set();
78
- let command;
79
- let sub;
80
- let sub2;
81
- for (let i = 0; i < argv.length; i++) {
82
- const arg = argv[i];
83
- if (arg === '-h')
84
- bools.add('help');
85
- else if (arg === '-v')
86
- bools.add('version');
87
- else if (arg.startsWith('--')) {
88
- const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
89
- if (VALUE_FLAGS.has(name)) {
90
- const value = inline ?? argv[++i];
91
- if (value === undefined)
92
- fail(`--${name} needs a value.`);
93
- flags.set(name, [...(flags.get(name) ?? []), value]);
94
- }
95
- else
96
- bools.add(name);
97
- }
98
- else if (!command)
99
- command = arg;
100
- else if (command === 'tour' && !sub)
101
- sub = arg;
102
- else if (command === 'tour' && sub === 'export' && !sub2)
103
- sub2 = arg;
104
- else
105
- fail(`Unexpected argument "${arg}".`);
106
- }
107
- return { command, sub, sub2, flags, bools };
108
- }
109
- function fail(message) {
110
- // Phrased without a command name on purpose: this is the message somebody
111
- // sees when they are already stuck, and it would be wrong for half of them.
112
- // `docugate --help` is not a thing you can run if you reached this through
113
- // npx, and `npx docugate --help` is not one if you installed it.
114
- console.error(`${red('error')} ${message}\n${dim('See --help for usage.')}`);
115
- process.exit(2);
116
- }
117
- function version() {
118
- const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
119
- return pkg.version;
26
+ /** True while the launch menu is running: its commands do not print their own "Next". */
27
+ let inMenu = false;
28
+ function fail(message, tip) {
29
+ throw new UsageError(message, tip);
120
30
  }
121
31
  function report(label, issues, paintLabel) {
122
32
  for (const issue of issues) {
123
33
  console.log(` ${paintLabel(label.padEnd(5))} ${issue.file ? `${issue.file} ` : ''}${issue.message}`);
124
34
  }
125
35
  }
126
- const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
127
- async function main() {
128
- const args = parse(process.argv.slice(2));
36
+ function printHelp() {
37
+ // The banner only in a real terminal: piped help stays plain text.
38
+ if (process.stdout.isTTY)
39
+ for (const line of banner())
40
+ console.log(line);
41
+ console.log(renderHelp(SITE));
42
+ }
43
+ /** After a command in a terminal: the one thing worth doing next. */
44
+ async function closing(root) {
45
+ if (inMenu || !process.stdout.isTTY || process.env.CI)
46
+ return;
47
+ try {
48
+ const status = await gatherStatus(root, { timeoutMs: 1500 });
49
+ if (status.next.done)
50
+ return;
51
+ console.log(`\n ${dim('Next:')} ${gold(hint(status.next.command))} ${dim(`— ${status.next.why}`)}\n`);
52
+ }
53
+ catch {
54
+ /* a hint is never worth an error */
55
+ }
56
+ }
57
+ function reportError(error) {
58
+ if (error instanceof UsageError) {
59
+ // Phrased without a command name on purpose: this is the message somebody
60
+ // sees when they are already stuck, and it would be wrong for half of them.
61
+ console.error(`${red('error')} ${error.message}\n${dim(error.hint ?? 'See --help for usage.')}`);
62
+ process.exitCode = 2;
63
+ }
64
+ else {
65
+ console.error(`${red('error')} ${error.message}`);
66
+ process.exitCode = 1;
67
+ }
68
+ }
69
+ /**
70
+ * Whether somebody is at the keyboard. A real terminal says so; Git Bash's
71
+ * mintty window on Windows does not, because Node sees its console as a pipe,
72
+ * so it is recognised by the name it puts in TERM_PROGRAM instead. There the menu is the numbered
73
+ * one, which needs no raw keys.
74
+ */
75
+ function isInteractive() {
76
+ if (process.stdin.isTTY && process.stdout.isTTY)
77
+ return true;
78
+ // mintty names itself; a script or a pipe run from Git Bash does not.
79
+ return isMintty() && !process.env.CI;
80
+ }
81
+ /** The launch menu: pick a step, run it, come back. */
82
+ async function menuLoop(root) {
83
+ inMenu = true;
84
+ // The menu only opens for somebody at the keyboard, so the logo always shows.
85
+ for (const line of banner())
86
+ console.log(line);
87
+ for (;;) {
88
+ const status = await gatherStatus(root);
89
+ const action = await runMenu(status, { numbered: !canPick() });
90
+ if (!action)
91
+ return;
92
+ try {
93
+ await run(action);
94
+ }
95
+ catch (error) {
96
+ reportError(error);
97
+ }
98
+ process.exitCode = 0;
99
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
100
+ const again = (await rl.question(dim('\n Press Enter for the menu, or q to quit. '))).trim().toLowerCase() !== 'q';
101
+ rl.close();
102
+ if (!again)
103
+ return;
104
+ }
105
+ }
106
+ async function run(argv) {
107
+ const args = parseArgs(argv);
129
108
  const one = (name) => args.flags.get(name)?.at(-1);
130
109
  const many = (name) => args.flags.get(name) ?? [];
131
110
  const root = process.cwd();
132
- if (args.bools.has('version'))
133
- return console.log(version());
134
- if (args.bools.has('help') || !args.command)
135
- return console.log(HELP);
111
+ // No question is ever asked in CI, in a pipe, or when told not to.
112
+ const noInput = args.bools.has('no-input') || Boolean(process.env.CI);
113
+ if (args.version)
114
+ return console.log(packageVersion());
115
+ if (args.help) {
116
+ if (args.spec)
117
+ return console.log(renderCommandHelp(args.spec));
118
+ if (args.group)
119
+ return console.log(renderGroupHelp(args.group));
120
+ return printHelp();
121
+ }
122
+ if (args.group) {
123
+ // A group on its own is a command that was not finished: show the choices, and say it failed.
124
+ console.log(renderGroupHelp(args.group));
125
+ process.exitCode = 2;
126
+ return;
127
+ }
128
+ if (!args.command) {
129
+ // `docugate` on its own opens the app, the way `claude` does: a person typed
130
+ // it. Help is for a pipe, CI, or --no-input, where nobody can answer.
131
+ if (!inMenu && !noInput && isInteractive())
132
+ return menuLoop(root);
133
+ return printHelp();
134
+ }
136
135
  switch (args.command) {
136
+ case 'help': {
137
+ const topic = args.positional;
138
+ if (!topic)
139
+ return printHelp();
140
+ const spec = findCommand(topic);
141
+ if (spec)
142
+ return console.log(renderCommandHelp(spec));
143
+ if (COMMANDS.some((c) => c.name.startsWith(`${topic} `)))
144
+ return console.log(renderGroupHelp(topic));
145
+ const close = suggest(topic, COMMANDS.map((c) => c.name));
146
+ return fail(`There is no command "${topic}".`, close ? `Did you mean "${close}"?` : 'Run `docugate help` to see every command.');
147
+ }
148
+ case 'completion': {
149
+ const script = renderCompletion(args.positional ?? '');
150
+ if (!script)
151
+ fail('Say which shell: bash, zsh or powershell.');
152
+ process.stdout.write(script);
153
+ return;
154
+ }
155
+ case 'status': {
156
+ const status = await gatherStatus(root);
157
+ if (args.bools.has('json'))
158
+ return console.log(JSON.stringify(status, null, 2));
159
+ console.log();
160
+ for (const line of renderStatus(status))
161
+ console.log(line);
162
+ console.log();
163
+ return;
164
+ }
137
165
  case 'login': {
138
- const creds = loadCredentials();
139
- if (creds) {
140
- // Already signed in — confirm with the server.
166
+ if (loadCredentials()) {
167
+ // Already signed in: confirm with the server.
141
168
  try {
142
169
  const { user } = await getSession();
143
170
  if (user) {
@@ -149,21 +176,16 @@ async function main() {
149
176
  // server unreachable; proceed to sign in again
150
177
  }
151
178
  }
152
- const { token } = await login(BASE_URL, args.bools.has('yes') ? {} : {});
179
+ await login(BASE_URL);
153
180
  try {
154
181
  const { user } = await getSession();
155
- if (user) {
156
- console.log(`Signed in as @${user.githubLogin}`);
157
- }
158
- else {
159
- console.log('Signed in.');
160
- }
182
+ console.log(user ? `Signed in as @${user.githubLogin}` : 'Signed in.');
161
183
  }
162
184
  catch {
163
185
  // token saved; just confirm
164
186
  console.log('Signed in.');
165
187
  }
166
- void token;
188
+ await closing(root);
167
189
  return;
168
190
  }
169
191
  case 'logout': {
@@ -172,9 +194,8 @@ async function main() {
172
194
  return;
173
195
  }
174
196
  case 'whoami': {
175
- const creds = loadCredentials();
176
- if (!creds) {
177
- console.log('Not signed in. Run docugate login.');
197
+ if (!loadCredentials() && !process.env.DOCUGATE_TOKEN) {
198
+ console.log(`Not signed in. Run ${hint('login')}.`);
178
199
  return;
179
200
  }
180
201
  let session;
@@ -186,12 +207,13 @@ async function main() {
186
207
  process.exitCode = 1;
187
208
  return;
188
209
  }
189
- if (session.user) {
190
- console.log(`@${session.user.githubLogin}`);
191
- }
192
- else {
193
- console.log('Not signed in. Run docugate login.');
194
- }
210
+ console.log(session.user ? `@${session.user.githubLogin}` : `Not signed in. Run ${hint('login')}.`);
211
+ return;
212
+ }
213
+ case 'developers': {
214
+ if (noInput && !args.bools.has('yes'))
215
+ fail('developers asks questions. Run it in a terminal, or add --yes to take every default.');
216
+ await developersFlow(root, { dir: one('dir'), yes: args.bools.has('yes') });
195
217
  return;
196
218
  }
197
219
  case 'init': {
@@ -206,6 +228,8 @@ async function main() {
206
228
  role: roleFlag,
207
229
  yes: args.bools.has('yes'),
208
230
  port,
231
+ // Under --no-input or in CI nothing may be asked, even in a terminal.
232
+ io: noInput ? { ...makeDefaultIO(), isTTY: false } : undefined,
209
233
  });
210
234
  // The Tour button only shows while the tour server runs, so setup starts
211
235
  // it in the background instead of asking: the next thing the person
@@ -215,11 +239,23 @@ async function main() {
215
239
  if (hasTour && !args.bools.has('no-serve')) {
216
240
  const started = await startServerInBackground(where, port);
217
241
  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.`));
242
+ ? ` The tour server is running in the background (port ${port}). After a restart: ${hint('tour serve')}`
243
+ : ` Start the tour server with ${hint('tour serve')}, then open your app.`));
220
244
  }
221
245
  return;
222
246
  }
247
+ case 'sync': {
248
+ process.exitCode = await syncCommand(root, {
249
+ sub: args.sub,
250
+ sub2: args.sub2,
251
+ since: one('since'),
252
+ model: one('model'),
253
+ maxFiles: one('max-files'),
254
+ freshness: one('freshness'),
255
+ bools: args.bools,
256
+ });
257
+ return;
258
+ }
223
259
  case 'check': {
224
260
  const result = check(root, one('dir'));
225
261
  const strict = args.bools.has('strict');
@@ -233,6 +269,8 @@ async function main() {
233
269
  console.log(`\n${plural(result.errors.length, 'error')}, ${plural(result.warnings.length, 'warning')}`);
234
270
  if (failed)
235
271
  process.exitCode = 1;
272
+ else
273
+ await closing(root);
236
274
  return;
237
275
  }
238
276
  case 'openapi': {
@@ -257,7 +295,7 @@ async function main() {
257
295
  }
258
296
  else {
259
297
  console.log(`${red('out of date')} ${output} does not match ${from}.`);
260
- console.log(dim('Run `docugate openapi` and commit the result.'));
298
+ console.log(dim(`Run ${hint('openapi')} and commit the result.`));
261
299
  process.exitCode = 1;
262
300
  }
263
301
  return;
@@ -287,6 +325,11 @@ async function main() {
287
325
  // already / no-op
288
326
  console.log(` ${dim('unchanged')} ${result.target}`);
289
327
  }
328
+ if (result.hookAction === 'inserted')
329
+ console.log(` ${green('installed')} package.json ${dim(`(${result.hook}: the tour server starts with your app)`)}`);
330
+ if (result.hookAction === 'removed')
331
+ console.log(` ${green('removed')} package.json ${dim(`(${result.hook})`)}`);
332
+ await closing(root);
290
333
  return;
291
334
  }
292
335
  case 'init': {
@@ -297,31 +340,24 @@ async function main() {
297
340
  }
298
341
  catch (error) {
299
342
  if (error instanceof BobKeyMissingError) {
300
- console.error(`${red('error')} Bob Shell needs an API key to run on its own (your IDE sign-in is not used here).
301
- ` +
302
- `1. At https://bob.ibm.com, open your instance, then API keys, and create an Inference key.
303
- ` +
304
- `2. Save it as the BOB_API_KEY environment variable, then open a new terminal:
305
- ` +
306
- ` PowerShell [Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User")
307
- ` +
308
- ` macOS/Linux export BOB_API_KEY=<key> (add it to your shell profile)
309
- ` +
343
+ console.error(`${red('error')} Bob Shell needs an API key to run on its own (your IDE sign-in is not used here).\n` +
344
+ `1. At https://bob.ibm.com, open your instance, then API keys, and create an Inference key.\n` +
345
+ `2. Save it as the BOB_API_KEY environment variable, then open a new terminal:\n` +
346
+ ` PowerShell [Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User")\n` +
347
+ ` macOS/Linux export BOB_API_KEY=<key> (add it to your shell profile)\n` +
310
348
  dim('Keep the key out of your repository. https://bob.ibm.com/docs/ide/account/api-keys'));
311
- process.exit(2);
349
+ process.exitCode = 2;
350
+ return;
312
351
  }
313
352
  if (!(error instanceof BobMissingError))
314
353
  throw error;
315
- console.error(`${red('error')} Bob Shell is not installed, and tours are written by IBM Bob.
316
- ` +
317
- `Install it (Node 24 or later), sign in, then run this again:
318
- ` +
319
- ` Windows powershell -c "irm -Uri https://bob.ibm.com/download/bobshell.ps1 | iex"
320
- ` +
321
- ` macOS/Linux curl -fsSL https://bob.ibm.com/download/bobshell.sh | bash
322
- ` +
354
+ console.error(`${red('error')} Bob Shell is not installed, and tours are written by IBM Bob.\n` +
355
+ `Install it (Node 24 or later), sign in, then run this again:\n` +
356
+ ` Windows powershell -c "irm -Uri https://bob.ibm.com/download/bobshell.ps1 | iex"\n` +
357
+ ` macOS/Linux curl -fsSL https://bob.ibm.com/download/bobshell.sh | bash\n` +
323
358
  dim('https://bob.ibm.com/docs/shell/getting-started/install-and-setup'));
324
- process.exit(2);
359
+ process.exitCode = 2;
360
+ return;
325
361
  }
326
362
  for (const f of result.setup)
327
363
  console.log(` ${green('created')} ${f}`);
@@ -337,17 +373,25 @@ async function main() {
337
373
  stats?.durationMs !== undefined ? `${Math.round(stats.durationMs / 1000)}s` : '',
338
374
  stats?.cost !== undefined ? `${stats.cost} Bobcoins` : '',
339
375
  ].filter(Boolean);
340
- console.log(`
341
- ${dim(`Bob: ${parts.join(', ')}`)}`);
376
+ console.log(`\n${dim(`Bob: ${parts.join(', ')}`)}`);
342
377
  console.log('Next: read the new files, fix anything Bob got wrong, and commit them. They are yours now.');
343
378
  return;
344
379
  }
345
380
  case 'serve': {
346
- await serveTour(root, parseInt(one('port') ?? '4178', 10));
381
+ const port = parseInt(one('port') ?? '4178', 10);
382
+ if (args.bools.has('background')) {
383
+ // Run from the app's predev script: start (or reuse) the server,
384
+ // say so in one line, and hand the terminal back to the app.
385
+ mkdirSync(join(root, '.docugate', 'tour'), { recursive: true });
386
+ const up = await startServerInBackground(root, port);
387
+ console.log(up ? `${green('DocuGate')} tour ready on port ${port}` : `${yellow('DocuGate')} could not start the tour server. Run ${hint('tour serve')} to see why.`);
388
+ return;
389
+ }
390
+ await serveTour(root, port);
347
391
  return;
348
392
  }
349
393
  case 'export': {
350
- const outFile = args.sub2;
394
+ const outFile = args.positional;
351
395
  if (!outFile)
352
396
  fail('Say where to write: docugate tour export <file>.');
353
397
  const screens = loadTours(root);
@@ -357,17 +401,22 @@ ${dim(`Bob: ${parts.join(', ')}`)}`);
357
401
  return;
358
402
  }
359
403
  default:
360
- fail(args.sub ? `Unknown tour command "${args.sub}".` : 'Say which: docugate tour init, tour install, or tour serve.');
404
+ fail('Say which: docugate tour init, tour install, tour serve, or tour export.');
361
405
  }
362
406
  }
363
407
  default:
364
408
  fail(`Unknown command "${args.command}".`);
365
409
  }
366
410
  }
367
- main().catch((error) => {
368
- console.error(`${red('error')} ${error.message}`);
369
- process.exit(1);
370
- });
411
+ main();
412
+ async function main() {
413
+ try {
414
+ await run(process.argv.slice(2));
415
+ }
416
+ catch (error) {
417
+ reportError(error);
418
+ }
419
+ }
371
420
  /**
372
421
  * Starts `docugate tour serve` detached, so it outlives this command. When one
373
422
  * is already answering on the port, that one is kept. True when a server is