docugate 0.2.4 → 0.2.6
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/init.js +152 -126
- package/dist/rules.js +1 -1
- package/dist/spinner.js +49 -0
- package/dist/tour-server.js +11 -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/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
|
/**
|
|
@@ -57,6 +58,67 @@ export function makeDefaultIO() {
|
|
|
57
58
|
},
|
|
58
59
|
};
|
|
59
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* A new space and the repository that holds its docs: the repository is
|
|
63
|
+
* created on the person's account with docs/index.md in it, then the space is
|
|
64
|
+
* made from it. Returns the space, or undefined when something stopped it
|
|
65
|
+
* (every reason is printed).
|
|
66
|
+
*/
|
|
67
|
+
async function createDocsSpace(io, projectName, baseUrl, options) {
|
|
68
|
+
const visibility = await choose(io, 'Who can read the docs repository on GitHub?', [
|
|
69
|
+
'Public',
|
|
70
|
+
'Private (a space from a private repository needs DocuGate Pro)',
|
|
71
|
+
]);
|
|
72
|
+
const repoName = `${projectName.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-|-$/g, '')}-docs`;
|
|
73
|
+
const stop = startSpinner(['Creating the docs repository on GitHub', 'Writing its first page', 'Almost there']);
|
|
74
|
+
let repo;
|
|
75
|
+
try {
|
|
76
|
+
repo = await (options.createDocsRepoFn ?? (async (name, priv, title) => {
|
|
77
|
+
const { createRepo } = await import('./api.js');
|
|
78
|
+
return createRepo(name, priv, { title });
|
|
79
|
+
}))(repoName, visibility === 2, projectName);
|
|
80
|
+
stop();
|
|
81
|
+
io.print(` Created ${repo.url}`);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
stop();
|
|
85
|
+
io.print(` warning ${err.message}`);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const createFn = options.createSpaceFn ?? (async (r, name, dir) => {
|
|
89
|
+
const { createSpace } = await import('./api.js');
|
|
90
|
+
return createSpace(r, name, dir);
|
|
91
|
+
});
|
|
92
|
+
// A space only ever reads a repository DocuGate can see. One installed on
|
|
93
|
+
// selected repositories may not include the new one yet: offer the install
|
|
94
|
+
// and try again once.
|
|
95
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
96
|
+
const stopSpace = startSpinner(['Setting up your space', 'Reading the docs repository']);
|
|
97
|
+
try {
|
|
98
|
+
const space = await createFn(repo.repo, projectName, 'docs');
|
|
99
|
+
stopSpace();
|
|
100
|
+
io.print(` Created space "${space.name}"`);
|
|
101
|
+
if (repo.seeded === false)
|
|
102
|
+
io.print(` Add markdown files to docs/ in ${repo.repo} and they become pages.`);
|
|
103
|
+
return space;
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
stopSpace();
|
|
107
|
+
io.print(` warning ${err.message}`);
|
|
108
|
+
if (err.code !== 'no_repo_access' || attempt > 0)
|
|
109
|
+
return undefined;
|
|
110
|
+
const fix = await choose(io, `The DocuGate GitHub App can't see ${repo.repo} yet.`, [
|
|
111
|
+
'Add it to the app (opens GitHub), then try again',
|
|
112
|
+
'Skip for now',
|
|
113
|
+
]);
|
|
114
|
+
if (fix !== 1)
|
|
115
|
+
return undefined;
|
|
116
|
+
(options.openUrl ?? openInBrowser)(`${baseUrl}/api/auth/github?install=1`);
|
|
117
|
+
await io.ask(` On GitHub, add ${repo.repo} (or choose All repositories) and save. Then press Enter here`, '');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
60
122
|
/**
|
|
61
123
|
* The nearest folder above `root`, inside the same Git repository, that has
|
|
62
124
|
* DocuGate set up already (a docugate.json), if any.
|
|
@@ -185,53 +247,6 @@ export async function choose(io, question, options, fallback = 1) {
|
|
|
185
247
|
io.print(` Type a number from 1 to ${options.length}.`);
|
|
186
248
|
}
|
|
187
249
|
}
|
|
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
250
|
function defaultGetRemote(root) {
|
|
236
251
|
try {
|
|
237
252
|
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
@@ -366,93 +381,76 @@ export async function initFlow(root, options = {}) {
|
|
|
366
381
|
}
|
|
367
382
|
}
|
|
368
383
|
}
|
|
369
|
-
// ── 3b:
|
|
384
|
+
// ── 3b: the space that holds this project's documentation ─────────────────
|
|
385
|
+
// A space reads markdown from a GitHub repository. A new space gets its own
|
|
386
|
+
// documentation repository, created on the person's account; an existing
|
|
387
|
+
// space can be picked instead. Either way docugate.json remembers it, so the
|
|
388
|
+
// pill can link to the published docs.
|
|
370
389
|
if (signedIn) {
|
|
371
|
-
|
|
372
|
-
let
|
|
373
|
-
|
|
374
|
-
|
|
390
|
+
const repoSlug = getRemoteFn(root);
|
|
391
|
+
let spaces = [];
|
|
392
|
+
try {
|
|
393
|
+
spaces = (await getSpacesFn()).spaces;
|
|
375
394
|
}
|
|
376
|
-
|
|
377
|
-
|
|
395
|
+
catch (err) {
|
|
396
|
+
connectError = `Couldn't load spaces: ${err.message}`;
|
|
397
|
+
io.print(` warning ${connectError}`);
|
|
378
398
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
399
|
+
const { docsDir: repoDocsDir, config } = loadConfig(root);
|
|
400
|
+
const projectName = config.title ?? repoSlug?.split('/')[1] ?? basename(root);
|
|
401
|
+
const hasRepo = (sp) => Boolean(repoSlug) && (sp.sources ?? []).some((src) => src.repo.toLowerCase() === repoSlug.toLowerCase());
|
|
402
|
+
const already = spaces.find(hasRepo);
|
|
403
|
+
const linkSpace = (sp) => {
|
|
404
|
+
saveSetting(root, 'space', `${sp.owner}/${sp.slug}`);
|
|
405
|
+
io.print(` Docs will be published at ${BASE_URL}/${sp.owner}/${sp.slug}`);
|
|
406
|
+
};
|
|
407
|
+
const connectProject = async (sp) => {
|
|
408
|
+
if (!repoSlug || hasRepo(sp))
|
|
409
|
+
return;
|
|
382
410
|
try {
|
|
383
|
-
|
|
384
|
-
|
|
411
|
+
await connectFn(sp.id, repoSlug, repoDocsDir);
|
|
412
|
+
io.print(` Connected ${repoSlug} to space "${sp.name}".`);
|
|
385
413
|
}
|
|
386
414
|
catch (err) {
|
|
387
|
-
connectError =
|
|
415
|
+
connectError = err.message;
|
|
388
416
|
io.print(` warning ${connectError}`);
|
|
389
417
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
418
|
+
};
|
|
419
|
+
if (connectError) {
|
|
420
|
+
// the warning is printed already; carry on with the rest of setup
|
|
421
|
+
}
|
|
422
|
+
else if (yes) {
|
|
423
|
+
// --yes: use the space only when there is no doubt which one is meant.
|
|
424
|
+
const only = already ?? (spaces.length === 1 ? spaces[0] : undefined);
|
|
425
|
+
if (only) {
|
|
426
|
+
await connectProject(only);
|
|
427
|
+
linkSpace(only);
|
|
428
|
+
}
|
|
429
|
+
else if (spaces.length > 1) {
|
|
430
|
+
io.print(` Several spaces found: run docugate init again without --yes to choose.`);
|
|
395
431
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
const choices = [
|
|
435
|
+
'Create a new space, with a new repository for its docs',
|
|
436
|
+
...spaces.map((sp) => `${sp.name} (${sp.owner}/${sp.slug})${hasRepo(sp) ? ' already has this project' : ''}`),
|
|
437
|
+
'Skip for now',
|
|
438
|
+
];
|
|
439
|
+
const pick = await choose(io, `Where should the documentation for ${projectName} live?`, choices, already ? spaces.indexOf(already) + 2 : 1);
|
|
440
|
+
if (pick === 1) {
|
|
441
|
+
const created = await createDocsSpace(io, projectName, BASE_URL, options);
|
|
442
|
+
if (created)
|
|
443
|
+
linkSpace(created);
|
|
444
|
+
else
|
|
445
|
+
connectError = 'The space was not created.';
|
|
446
|
+
}
|
|
447
|
+
else if (pick <= spaces.length + 1) {
|
|
448
|
+
const sp = spaces[pick - 2];
|
|
449
|
+
await connectProject(sp);
|
|
450
|
+
linkSpace(sp);
|
|
414
451
|
}
|
|
415
452
|
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
|
-
}
|
|
453
|
+
io.print(` Skipped. docugate init sets it up any time.`);
|
|
456
454
|
}
|
|
457
455
|
}
|
|
458
456
|
}
|
|
@@ -481,18 +479,46 @@ export async function initFlow(root, options = {}) {
|
|
|
481
479
|
if (pick === 2)
|
|
482
480
|
io.print(' IBM watsonx is coming soon. Skipped for now: run docugate tour init when you are ready.');
|
|
483
481
|
runTour = pick === 1;
|
|
484
|
-
if (runTour && !readBobKey() && !options.runBob)
|
|
485
|
-
|
|
482
|
+
if (runTour && !readBobKey() && !options.runBob) {
|
|
483
|
+
// The key may be in the environment under another name.
|
|
484
|
+
const [other] = bobKeyCandidates();
|
|
485
|
+
if (other) {
|
|
486
|
+
const use = await choose(io, `BOB_API_KEY is not set, but ${other} is. Is that your IBM Bob key?`, [
|
|
487
|
+
`Yes, use ${other} for this run`,
|
|
488
|
+
'No',
|
|
489
|
+
]);
|
|
490
|
+
if (use === 1) {
|
|
491
|
+
useBobKeyForThisRun(process.env[other]);
|
|
492
|
+
io.print(` Tip: rename it to BOB_API_KEY in your environment so Bob finds it next time.`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (!readBobKey())
|
|
496
|
+
runTour = await askForBobKey(io, options.openUrl);
|
|
497
|
+
}
|
|
486
498
|
}
|
|
487
499
|
if (runTour) {
|
|
500
|
+
// Bob takes a minute or two. Show it working, the way an assistant does.
|
|
501
|
+
const stop = options.runBob
|
|
502
|
+
? () => undefined
|
|
503
|
+
: startSpinner([
|
|
504
|
+
'IBM Bob is reading your screens and routes',
|
|
505
|
+
'Following each value to the endpoint it comes from',
|
|
506
|
+
'Tracing it into the backend',
|
|
507
|
+
'Writing the tour, one step per element',
|
|
508
|
+
'Checking every file it names exists',
|
|
509
|
+
'Still working: bigger apps take a little longer',
|
|
510
|
+
]);
|
|
488
511
|
try {
|
|
489
512
|
tourResult = tourInit(root, { runBob: options.runBob });
|
|
513
|
+
stop();
|
|
514
|
+
io.print(` IBM Bob wrote the tour.`);
|
|
490
515
|
}
|
|
491
516
|
catch (error) {
|
|
517
|
+
stop();
|
|
492
518
|
// Bob missing or failed — describe what went wrong, then continue.
|
|
493
519
|
if (error instanceof BobKeyMissingError) {
|
|
494
520
|
tourError =
|
|
495
|
-
`Bob
|
|
521
|
+
`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
522
|
}
|
|
497
523
|
else if (error instanceof BobMissingError) {
|
|
498
524
|
tourError =
|
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-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');
|
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. */
|