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 +4 -3
- package/dist/api.js +14 -1
- package/dist/cli.js +102 -72
- package/dist/init.js +228 -133
- package/dist/rules.js +1 -1
- package/dist/spinner.js +49 -0
- package/dist/tour-init.js +31 -8
- package/dist/tour-server.js +24 -2
- package/package.json +1 -1
- package/pill/dist/pill.js +23 -5
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
|
-
|
|
|
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
|
|
54
|
-
|
|
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 {
|
|
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
|
|
210
|
-
//
|
|
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 &&
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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}]
|
|
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:
|
|
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
|
-
|
|
372
|
-
let
|
|
373
|
-
|
|
374
|
-
|
|
428
|
+
const repoSlug = getRemoteFn(root);
|
|
429
|
+
let spaces = [];
|
|
430
|
+
try {
|
|
431
|
+
spaces = (await getSpacesFn()).spaces;
|
|
375
432
|
}
|
|
376
|
-
|
|
377
|
-
|
|
433
|
+
catch (err) {
|
|
434
|
+
connectError = `Couldn't load spaces: ${err.message}`;
|
|
435
|
+
io.print(` warning ${connectError}`);
|
|
378
436
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
449
|
+
await connectFn(sp.id, repoSlug, repoDocsDir);
|
|
450
|
+
io.print(` Connected ${repoSlug} to space "${sp.name}".`);
|
|
385
451
|
}
|
|
386
452
|
catch (err) {
|
|
387
|
-
connectError =
|
|
453
|
+
connectError = err.message;
|
|
388
454
|
io.print(` warning ${connectError}`);
|
|
389
455
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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 (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
else
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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']);
|
package/dist/spinner.js
ADDED
|
@@ -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
|
|
91
|
-
|
|
92
|
-
encoding: 'utf8',
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
}
|
package/dist/tour-server.js
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
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
|
|
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. */
|