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