docugate 0.2.0 → 0.2.2
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 +252 -251
- package/dist/api.js +12 -0
- package/dist/init.js +218 -51
- package/dist/rules.js +1 -1
- package/dist/tour-init.js +23 -1
- package/dist/tour-server.js +114 -5
- package/package.json +1 -1
- package/pill/dist/pill.js +926 -422
package/dist/init.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { basename, join } from 'node:path';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
5
|
import { CONFIG_FILE, loadConfig } from './config.js';
|
|
6
|
-
import { tourInit, BobMissingError, BobKeyMissingError } from './tour-init.js';
|
|
6
|
+
import { tourInit, BobMissingError, BobKeyMissingError, readBobKey, saveBobKey } from './tour-init.js';
|
|
7
7
|
import { tourInstall, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
|
|
8
8
|
import { login, loadCredentials } from './auth.js';
|
|
9
9
|
/**
|
|
@@ -57,6 +57,80 @@ function makeDefaultIO() {
|
|
|
57
57
|
},
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The nearest folder above `root`, inside the same Git repository, that has
|
|
62
|
+
* DocuGate set up already (a docugate.json), if any.
|
|
63
|
+
*/
|
|
64
|
+
export function setUpAbove(root) {
|
|
65
|
+
if (existsSync(join(root, '.git')))
|
|
66
|
+
return undefined;
|
|
67
|
+
let dir = dirname(root);
|
|
68
|
+
while (dir !== dirname(dir)) {
|
|
69
|
+
if (existsSync(join(dir, CONFIG_FILE)))
|
|
70
|
+
return dir;
|
|
71
|
+
if (existsSync(join(dir, '.git')))
|
|
72
|
+
return undefined;
|
|
73
|
+
dir = dirname(dir);
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/** Sets one key in docugate.json, keeping everything else as it was. */
|
|
78
|
+
function saveSetting(root, key, value) {
|
|
79
|
+
const path = join(root, CONFIG_FILE);
|
|
80
|
+
let existing = {};
|
|
81
|
+
if (existsSync(path)) {
|
|
82
|
+
try {
|
|
83
|
+
existing = JSON.parse(readFileSync(path, 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// unreadable: start again with this key
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
existing[key] = value;
|
|
90
|
+
writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
|
|
91
|
+
}
|
|
92
|
+
const BOB_KEYS_URL = 'https://bob.ibm.com/docs/ide/account/api-keys';
|
|
93
|
+
/**
|
|
94
|
+
* Bob Shell runs on its own with an API key, not the IDE sign-in. Setup gets
|
|
95
|
+
* one without anyone editing environment variables: open the page that makes
|
|
96
|
+
* it, paste it, and it is kept in ~/.docugate/bob.json, outside the repository.
|
|
97
|
+
*/
|
|
98
|
+
async function askForBobKey(io, openUrl) {
|
|
99
|
+
const pick = await choose(io, 'IBM Bob needs an API key to write the tour. Get one now?', [
|
|
100
|
+
'Yes, open the page to create an Inference key, then paste it here',
|
|
101
|
+
'Skip the tour for now',
|
|
102
|
+
]);
|
|
103
|
+
if (pick !== 1)
|
|
104
|
+
return false;
|
|
105
|
+
io.print(` In your Bob instance: API keys, then create an Inference key.`);
|
|
106
|
+
io.print(` ${BOB_KEYS_URL}`);
|
|
107
|
+
(openUrl ?? openInBrowser)(BOB_KEYS_URL);
|
|
108
|
+
const key = (await io.ask(' Paste the key and press Enter (it stays on this computer)', '')).trim();
|
|
109
|
+
if (!key) {
|
|
110
|
+
io.print(' No key pasted. Skipped the tour: run docugate tour init when you have one.');
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
saveBobKey(key);
|
|
114
|
+
io.print(' Key saved in ~/.docugate/bob.json. It is never written into the repository.');
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
function openInBrowser(url) {
|
|
118
|
+
const [cmd, args] = process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
119
|
+
: process.platform === 'darwin' ? ['open', [url]]
|
|
120
|
+
: ['xdg-open', [url]];
|
|
121
|
+
try {
|
|
122
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// the URL is printed above
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export const FRESHNESS = [
|
|
129
|
+
{ value: 'push', label: 'On every push' },
|
|
130
|
+
{ value: 'daily', label: 'Once a day (recommended: fewer AI runs)' },
|
|
131
|
+
{ value: 'weekly', label: 'Once a week' },
|
|
132
|
+
{ value: 'off', label: 'Never, I will ask for it' },
|
|
133
|
+
];
|
|
60
134
|
function saveRole(root, role) {
|
|
61
135
|
const path = join(root, CONFIG_FILE);
|
|
62
136
|
let existing = {};
|
|
@@ -71,7 +145,43 @@ function saveRole(root, role) {
|
|
|
71
145
|
existing.role = role;
|
|
72
146
|
writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
|
|
73
147
|
}
|
|
74
|
-
const
|
|
148
|
+
const ROLE_CHOICES = [{ role: 'frontend' }, { role: 'backend' }, { role: 'both' }];
|
|
149
|
+
const FRONTEND_DIRS = ['frontend', 'client', 'web'];
|
|
150
|
+
const BACKEND_DIRS = ['backend', 'server', 'api'];
|
|
151
|
+
const FRONTEND_MARKERS = ['index.html', 'vite.config.ts', 'vite.config.js', 'next.config.js', 'next.config.mjs', 'next.config.ts', 'angular.json', 'svelte.config.js'];
|
|
152
|
+
const BACKEND_MARKERS = ['go.mod', 'requirements.txt', 'pyproject.toml', 'manage.py', 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json'];
|
|
153
|
+
/**
|
|
154
|
+
* What the repository probably is, from its folders and files, so the question
|
|
155
|
+
* can suggest an answer. Only a suggestion: the person always confirms it.
|
|
156
|
+
*/
|
|
157
|
+
export function guessRole(root) {
|
|
158
|
+
const has = (name) => existsSync(join(root, name));
|
|
159
|
+
const front = FRONTEND_DIRS.some(has);
|
|
160
|
+
const back = BACKEND_DIRS.some(has);
|
|
161
|
+
if (front && back)
|
|
162
|
+
return 'both';
|
|
163
|
+
if (FRONTEND_MARKERS.some(has) || front)
|
|
164
|
+
return 'frontend';
|
|
165
|
+
if (BACKEND_MARKERS.some(has) || back)
|
|
166
|
+
return 'backend';
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* A numbered choice: prints the options, and takes only a number. Enter picks
|
|
171
|
+
* the default. Nobody types a word during setup.
|
|
172
|
+
*/
|
|
173
|
+
export async function choose(io, question, options, fallback = 1) {
|
|
174
|
+
io.print('');
|
|
175
|
+
io.print(` ${question}`);
|
|
176
|
+
options.forEach((option, i) => io.print(` ${i + 1} ${option}`));
|
|
177
|
+
for (;;) {
|
|
178
|
+
const answer = (await io.ask(` Choose 1-${options.length} [${fallback}]`, String(fallback))).trim();
|
|
179
|
+
const n = Number(answer);
|
|
180
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length)
|
|
181
|
+
return n;
|
|
182
|
+
io.print(` Type a number from 1 to ${options.length}.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
75
185
|
function defaultGetRemote(root) {
|
|
76
186
|
try {
|
|
77
187
|
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
@@ -97,6 +207,16 @@ export async function initFlow(root, options = {}) {
|
|
|
97
207
|
const io = options.io ?? makeDefaultIO();
|
|
98
208
|
const yes = options.yes ?? false;
|
|
99
209
|
const port = options.port ?? DEFAULT_PORT;
|
|
210
|
+
// One project gets one setup and one tour. Run inside frontend/ of a
|
|
211
|
+
// project already set up at its root, and init points there instead of
|
|
212
|
+
// starting a second one.
|
|
213
|
+
const parent = setUpAbove(root);
|
|
214
|
+
if (parent) {
|
|
215
|
+
io.print(` This project is already set up in ${parent}.`);
|
|
216
|
+
io.print(` One project has one tour: run docugate commands from that folder.`);
|
|
217
|
+
const above = loadConfig(parent);
|
|
218
|
+
return { created: [], kept: [], docsDir: above.docsDir, role: above.config.role, alreadySetUp: parent };
|
|
219
|
+
}
|
|
100
220
|
// ── Step 1: base repo setup (docugate.json + docs/) ──────────────────────
|
|
101
221
|
const base = init(root, { dir: options.dir, title: options.title });
|
|
102
222
|
// ── Step 2: determine role ────────────────────────────────────────────────
|
|
@@ -116,15 +236,19 @@ export async function initFlow(root, options = {}) {
|
|
|
116
236
|
role = 'both';
|
|
117
237
|
}
|
|
118
238
|
else {
|
|
119
|
-
// Interactive:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
239
|
+
// Interactive: a numbered choice with examples, the likely answer
|
|
240
|
+
// suggested from the folders in the repository.
|
|
241
|
+
const guess = guessRole(root);
|
|
242
|
+
const guessNumber = String(ROLE_CHOICES.findIndex((c) => c.role === guess) + 1);
|
|
243
|
+
const n = await choose(io, `What is in this repository?${guess ? ` (it looks like ${guess})` : ''}`, [
|
|
244
|
+
'Frontend the app people see: React, Vue, Svelte, Next.js, plain HTML',
|
|
245
|
+
'Backend the API or server: Node, Python, Go, Java, ...',
|
|
246
|
+
'Both frontend and backend in one repository, for example:\n' +
|
|
247
|
+
' my-app/\n' +
|
|
248
|
+
' ├─ frontend/ (or client/, web/)\n' +
|
|
249
|
+
' └─ backend/ (or server/, api/)',
|
|
250
|
+
], guessNumber === '0' ? 3 : Number(guessNumber));
|
|
251
|
+
role = ROLE_CHOICES[n - 1].role;
|
|
128
252
|
}
|
|
129
253
|
}
|
|
130
254
|
if (!savedRole) {
|
|
@@ -174,8 +298,11 @@ export async function initFlow(root, options = {}) {
|
|
|
174
298
|
if (!signedIn) {
|
|
175
299
|
let doLogin = yes;
|
|
176
300
|
if (!yes) {
|
|
177
|
-
|
|
178
|
-
|
|
301
|
+
doLogin =
|
|
302
|
+
(await choose(io, 'Sign in to DocuGate, so this repository can feed a space?', [
|
|
303
|
+
'Yes, open the browser to sign in',
|
|
304
|
+
'Skip for now (docugate login does it later)',
|
|
305
|
+
])) === 1;
|
|
179
306
|
}
|
|
180
307
|
if (doLogin) {
|
|
181
308
|
try {
|
|
@@ -207,55 +334,84 @@ export async function initFlow(root, options = {}) {
|
|
|
207
334
|
connectError = `Couldn't load spaces: ${err.message}`;
|
|
208
335
|
io.print(` warning ${connectError}`);
|
|
209
336
|
}
|
|
210
|
-
|
|
211
|
-
|
|
337
|
+
const { docsDir: repoDocsDir } = loadConfig(root);
|
|
338
|
+
const hasRepo = (sp) => (sp.sources ?? []).some((src) => src.repo.toLowerCase() === repoSlug.toLowerCase());
|
|
339
|
+
const already = spaces.find(hasRepo);
|
|
340
|
+
if (connectError) {
|
|
341
|
+
// the warning is printed already; carry on with the rest of setup
|
|
212
342
|
}
|
|
213
|
-
else if (
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
catch (err) {
|
|
223
|
-
connectError = err.message;
|
|
224
|
-
io.print(` warning ${connectError}`);
|
|
225
|
-
}
|
|
343
|
+
else if (yes) {
|
|
344
|
+
// --yes: connect only when there is no doubt which space is meant.
|
|
345
|
+
if (already) {
|
|
346
|
+
io.print(` ${repoSlug} is already in space "${already.name}".`);
|
|
347
|
+
}
|
|
348
|
+
else if (spaces.length === 1) {
|
|
349
|
+
try {
|
|
350
|
+
await connectFn(spaces[0].id, repoSlug, repoDocsDir);
|
|
351
|
+
io.print(` Connected ${repoSlug} to space "${spaces[0].name}".`);
|
|
226
352
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
io.print(` ${s.owner}/${s.slug} ${s.name}`);
|
|
353
|
+
catch (err) {
|
|
354
|
+
connectError = err.message;
|
|
355
|
+
io.print(` warning ${connectError}`);
|
|
231
356
|
}
|
|
232
357
|
}
|
|
233
|
-
else {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
358
|
+
else if (spaces.length > 1) {
|
|
359
|
+
io.print(` Several spaces found: run docugate init again without --yes to choose.`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
// Interactive: create a space, or pick one, or skip. Numbers only.
|
|
364
|
+
const choices = [
|
|
365
|
+
`Create a new space for ${repoSlug}`,
|
|
366
|
+
...spaces.map((sp) => `${sp.name} (${sp.owner}/${sp.slug})${hasRepo(sp) ? ' already reads this repository' : ''}`),
|
|
367
|
+
'Skip for now',
|
|
368
|
+
];
|
|
369
|
+
const pick = await choose(io, `Which DocuGate space should document ${repoSlug}?`, choices, already ? spaces.indexOf(already) + 2 : 1);
|
|
370
|
+
if (pick === 1) {
|
|
371
|
+
try {
|
|
372
|
+
const createFn = options.createSpaceFn ?? (async (repo, name, dir) => {
|
|
373
|
+
const { createSpace } = await import('./api.js');
|
|
374
|
+
return createSpace(repo, name, dir);
|
|
375
|
+
});
|
|
376
|
+
const created = await createFn(repoSlug, loadConfig(root).config.title ?? repoSlug.split('/')[1], repoDocsDir);
|
|
377
|
+
io.print(` Created space "${created.name}": ${BASE_URL}/${created.owner}/${created.slug}`);
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
connectError = err.message;
|
|
381
|
+
io.print(` warning ${connectError}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else if (pick <= spaces.length + 1) {
|
|
385
|
+
const sp = spaces[pick - 2];
|
|
386
|
+
if (hasRepo(sp)) {
|
|
387
|
+
io.print(` ${repoSlug} is already in space "${sp.name}". Nothing to change.`);
|
|
238
388
|
}
|
|
239
|
-
|
|
240
|
-
const idx = parseInt(answer, 10) - 1;
|
|
241
|
-
if (!isNaN(idx) && idx >= 0 && idx < spaces.length) {
|
|
389
|
+
else {
|
|
242
390
|
try {
|
|
243
|
-
await connectFn(
|
|
244
|
-
io.print(` Connected ${repoSlug} to space "${
|
|
391
|
+
await connectFn(sp.id, repoSlug, repoDocsDir);
|
|
392
|
+
io.print(` Connected ${repoSlug} to space "${sp.name}".`);
|
|
245
393
|
}
|
|
246
394
|
catch (err) {
|
|
247
395
|
connectError = err.message;
|
|
248
396
|
io.print(` warning ${connectError}`);
|
|
249
397
|
}
|
|
250
398
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
io.print(` Skipped. docugate init connects it any time.`);
|
|
254
402
|
}
|
|
255
403
|
}
|
|
256
404
|
}
|
|
257
405
|
}
|
|
258
406
|
}
|
|
407
|
+
// ── Step 3c: how often the docs are checked against the code ─────────────
|
|
408
|
+
if (io.isTTY && !loaded.config.freshness) {
|
|
409
|
+
const n = yes
|
|
410
|
+
? 2
|
|
411
|
+
: await choose(io, 'How often should DocuGate check that your docs still match the code?', FRESHNESS.map((f) => f.label), 2);
|
|
412
|
+
saveSetting(root, 'freshness', FRESHNESS[n - 1].value);
|
|
413
|
+
io.print(` Saved: ${FRESHNESS[n - 1].label.split(' (')[0].toLowerCase()}. Change "freshness" in docugate.json any time.`);
|
|
414
|
+
}
|
|
259
415
|
// ── Step 4: tour steps (frontend / both only) ─────────────────────────────
|
|
260
416
|
let tourResult;
|
|
261
417
|
let tourError;
|
|
@@ -264,8 +420,16 @@ export async function initFlow(root, options = {}) {
|
|
|
264
420
|
// Decide whether to run tour init.
|
|
265
421
|
let runTour = yes;
|
|
266
422
|
if (!yes && io.isTTY) {
|
|
267
|
-
const
|
|
268
|
-
|
|
423
|
+
const pick = await choose(io, 'Set up the tour: which AI should read your code and write it?', [
|
|
424
|
+
'IBM Bob (uses Bobcoins)',
|
|
425
|
+
'IBM watsonx (coming soon)',
|
|
426
|
+
'Skip, I will write it in the browser',
|
|
427
|
+
], 3);
|
|
428
|
+
if (pick === 2)
|
|
429
|
+
io.print(' IBM watsonx is coming soon. Skipped for now: run docugate tour init when you are ready.');
|
|
430
|
+
runTour = pick === 1;
|
|
431
|
+
if (runTour && !readBobKey() && !options.runBob)
|
|
432
|
+
runTour = await askForBobKey(io, options.openUrl);
|
|
269
433
|
}
|
|
270
434
|
if (runTour) {
|
|
271
435
|
try {
|
|
@@ -290,8 +454,11 @@ export async function initFlow(root, options = {}) {
|
|
|
290
454
|
// tour install: ask in TTY unless --yes.
|
|
291
455
|
let runInstall = yes;
|
|
292
456
|
if (!yes && io.isTTY) {
|
|
293
|
-
|
|
294
|
-
|
|
457
|
+
runInstall =
|
|
458
|
+
(await choose(io, 'Add the DocuGate pill to your app? (development only)', [
|
|
459
|
+
'Yes, add it',
|
|
460
|
+
'Skip, I will run docugate tour install later',
|
|
461
|
+
])) === 1;
|
|
295
462
|
}
|
|
296
463
|
if (runInstall) {
|
|
297
464
|
installResult = tourInstall(root, { port });
|
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', 'sidebar', 'api']);
|
|
61
|
+
export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'role', 'freshness', 'sidebar', 'api']);
|
package/dist/tour-init.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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 {
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
5
6
|
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
6
7
|
// `docugate tour init` writes the first tour of an app by asking IBM Bob
|
|
7
8
|
// (through Bob Shell) to read the code. Bob does the reading; this file makes
|
|
@@ -75,9 +76,30 @@ export class BobMissingError extends Error {
|
|
|
75
76
|
/** Bob Shell's headless mode signs in with an API key, not the IDE's IBMid session. */
|
|
76
77
|
export class BobKeyMissingError extends Error {
|
|
77
78
|
}
|
|
79
|
+
/** Where `docugate init` keeps the Bob API key: the home folder, never the repository. */
|
|
80
|
+
export function bobKeyPath() {
|
|
81
|
+
return join(homedir(), '.docugate', 'bob.json');
|
|
82
|
+
}
|
|
83
|
+
/** The Bob API key: BOB_API_KEY if set, else the one saved during setup. */
|
|
84
|
+
export function readBobKey() {
|
|
85
|
+
if (process.env.BOB_API_KEY)
|
|
86
|
+
return process.env.BOB_API_KEY;
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(readFileSync(bobKeyPath(), 'utf8')).key || undefined;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function saveBobKey(key) {
|
|
95
|
+
mkdirSync(dirname(bobKeyPath()), { recursive: true });
|
|
96
|
+
writeFileSync(bobKeyPath(), JSON.stringify({ key }) + '\n', { mode: 0o600 });
|
|
97
|
+
}
|
|
78
98
|
const defaultRunBob = (args, cwd) => {
|
|
99
|
+
const key = readBobKey();
|
|
79
100
|
const result = spawnSync('bob', args, {
|
|
80
101
|
cwd,
|
|
102
|
+
env: key ? { ...process.env, BOB_API_KEY: key } : process.env,
|
|
81
103
|
encoding: 'utf8',
|
|
82
104
|
// `bob` is a .cmd shim on Windows, which only runs through a shell.
|
|
83
105
|
shell: process.platform === 'win32',
|
package/dist/tour-server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
1
2
|
import { createServer } from 'node:http';
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, relative, resolve, isAbsolute } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { loadTours, matchRoute, parseTourFile, routeToFilename, serializeTourFile } from './tour.js';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -94,7 +95,7 @@ function handleGetTour(req, res, root) {
|
|
|
94
95
|
sendJson(res, 404, { error: 'not found' });
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
|
-
sendJson(res, 200, { route: match.route, title: match.title, stops: match.stops });
|
|
98
|
+
sendJson(res, 200, { route: match.route, title: match.title, file: match.file, stops: match.stops });
|
|
98
99
|
}
|
|
99
100
|
function handleGetEvents(req, res, root) {
|
|
100
101
|
const tourDir = join(root, '.docugate', 'tour');
|
|
@@ -134,7 +135,7 @@ async function handlePutStop(req, res, root) {
|
|
|
134
135
|
sendJson(res, 400, { error: 'invalid JSON body' });
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
137
|
-
const { route, title: bodyTitle, stop } = body;
|
|
138
|
+
const { route, title: bodyTitle, stop, previousTarget } = body;
|
|
138
139
|
if (!route || !stop) {
|
|
139
140
|
sendJson(res, 400, { error: 'body must have route and stop' });
|
|
140
141
|
return;
|
|
@@ -161,8 +162,9 @@ async function handlePutStop(req, res, root) {
|
|
|
161
162
|
stops: [],
|
|
162
163
|
};
|
|
163
164
|
}
|
|
164
|
-
// Upsert: find by target
|
|
165
|
-
|
|
165
|
+
// Upsert: find the stop being edited (by its old target when the edit
|
|
166
|
+
// changed it) and update it in place, or append a new one.
|
|
167
|
+
const idx = tour.stops.findIndex((s) => s.target === (previousTarget ?? stop.target));
|
|
166
168
|
if (idx >= 0) {
|
|
167
169
|
tour.stops[idx] = stop;
|
|
168
170
|
}
|
|
@@ -172,6 +174,91 @@ async function handlePutStop(req, res, root) {
|
|
|
172
174
|
writeFileSync(filepath, serializeTourFile(tour));
|
|
173
175
|
sendJson(res, 200, { ok: true });
|
|
174
176
|
}
|
|
177
|
+
/** DELETE /stop { route, target }: removes one stop, leaving the rest of the file as it was. */
|
|
178
|
+
async function handleDeleteStop(req, res, root) {
|
|
179
|
+
let body;
|
|
180
|
+
try {
|
|
181
|
+
body = JSON.parse(await readBody(req));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
sendJson(res, 400, { error: 'invalid JSON body' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const existing = loadTours(root).find((t) => t.route === body.route);
|
|
188
|
+
if (!existing?.file || !body.target) {
|
|
189
|
+
sendJson(res, 404, { error: 'no such stop' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const filepath = join(root, existing.file);
|
|
193
|
+
const tour = parseTourFile(readFileSync(filepath, 'utf8'), existing.file);
|
|
194
|
+
const before = tour.stops.length;
|
|
195
|
+
tour.stops = tour.stops.filter((s) => s.target !== body.target);
|
|
196
|
+
if (tour.stops.length === before) {
|
|
197
|
+
sendJson(res, 404, { error: 'no such stop' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
writeFileSync(filepath, serializeTourFile(tour));
|
|
201
|
+
sendJson(res, 200, { ok: true });
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The editor `docugate tour serve` was started from, as the command that opens
|
|
205
|
+
* a file at a line. DOCUGATE_EDITOR wins; otherwise the integrated terminal's
|
|
206
|
+
* environment says which VS Code family editor it is.
|
|
207
|
+
*/
|
|
208
|
+
export function editorCommand(env = process.env) {
|
|
209
|
+
if (env.DOCUGATE_EDITOR)
|
|
210
|
+
return env.DOCUGATE_EDITOR;
|
|
211
|
+
const hint = `${env.VSCODE_GIT_ASKPASS_NODE ?? ''} ${env.VSCODE_CWD ?? ''} ${env.CURSOR_TRACE_ID ? 'cursor' : ''}`.toLowerCase();
|
|
212
|
+
if (hint.includes('cursor'))
|
|
213
|
+
return 'cursor';
|
|
214
|
+
if (hint.includes('windsurf'))
|
|
215
|
+
return 'windsurf';
|
|
216
|
+
if (hint.includes('insiders'))
|
|
217
|
+
return 'code-insiders';
|
|
218
|
+
return 'code';
|
|
219
|
+
}
|
|
220
|
+
/** The 1-based line of the first occurrence of `find` in `text`, or 1. */
|
|
221
|
+
export function lineOf(text, find) {
|
|
222
|
+
if (!find)
|
|
223
|
+
return 1;
|
|
224
|
+
const index = text.split(/\r?\n/).findIndex((line) => line.includes(find));
|
|
225
|
+
return index >= 0 ? index + 1 : 1;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* POST /open { path, find? }: opens a file of this repository in the editor,
|
|
229
|
+
* at the line that contains `find` (the element's data-tour value), so "Code"
|
|
230
|
+
* in the pill jumps straight to the element. Only files inside the repository.
|
|
231
|
+
*/
|
|
232
|
+
async function handleOpen(req, res, root) {
|
|
233
|
+
let body;
|
|
234
|
+
try {
|
|
235
|
+
body = JSON.parse(await readBody(req));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
sendJson(res, 400, { error: 'invalid JSON body' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const abs = resolve(root, body.path ?? '');
|
|
242
|
+
const rel = relative(root, abs);
|
|
243
|
+
if (!body.path || rel.startsWith('..') || isAbsolute(rel) || !existsSync(abs) || /["%^&|<>`$]/.test(abs)) {
|
|
244
|
+
sendJson(res, 404, { error: 'no such file in this repository' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const line = lineOf(readFileSync(abs, 'utf8'), body.find);
|
|
248
|
+
const command = editorCommand();
|
|
249
|
+
// One command string, the path quoted: editor launchers are .cmd files on
|
|
250
|
+
// Windows, which only run through a shell.
|
|
251
|
+
const child = spawn(`${command} -g "${abs}:${line}"`, { shell: true, stdio: 'ignore' });
|
|
252
|
+
child.on('error', () => sendJson(res, 501, { error: `could not run ${command}` }));
|
|
253
|
+
child.on('exit', (code) => {
|
|
254
|
+
if (res.headersSent)
|
|
255
|
+
return;
|
|
256
|
+
if (code === 0)
|
|
257
|
+
sendJson(res, 200, { ok: true, line });
|
|
258
|
+
else
|
|
259
|
+
sendJson(res, 501, { error: `could not run ${command}`, line });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
175
262
|
function handleGetPill(res) {
|
|
176
263
|
// pill/dist/pill.js is resolved relative to this package's root directory.
|
|
177
264
|
// import.meta.url points at dist/tour-server.js at runtime, so go up one level.
|
|
@@ -194,6 +281,18 @@ export function startTourServer(root, port) {
|
|
|
194
281
|
}
|
|
195
282
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
196
283
|
const path = url.pathname;
|
|
284
|
+
// The pill runs on the app's own port, so a JSON PUT from it is a
|
|
285
|
+
// cross-origin request: the browser asks first, and must get a yes.
|
|
286
|
+
if (req.method === 'OPTIONS') {
|
|
287
|
+
res.writeHead(204, {
|
|
288
|
+
'Access-Control-Allow-Origin': '*',
|
|
289
|
+
'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE',
|
|
290
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
291
|
+
'Access-Control-Max-Age': '600',
|
|
292
|
+
});
|
|
293
|
+
res.end();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
197
296
|
if (req.method === 'GET' && path === '/tour') {
|
|
198
297
|
handleGetTour(req, res, root);
|
|
199
298
|
}
|
|
@@ -205,6 +304,16 @@ export function startTourServer(root, port) {
|
|
|
205
304
|
sendJson(res, 500, { error: err.message });
|
|
206
305
|
});
|
|
207
306
|
}
|
|
307
|
+
else if (req.method === 'DELETE' && path === '/stop') {
|
|
308
|
+
handleDeleteStop(req, res, root).catch((err) => {
|
|
309
|
+
sendJson(res, 500, { error: err.message });
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else if (req.method === 'POST' && path === '/open') {
|
|
313
|
+
handleOpen(req, res, root).catch((err) => {
|
|
314
|
+
sendJson(res, 500, { error: err.message });
|
|
315
|
+
});
|
|
316
|
+
}
|
|
208
317
|
else if (req.method === 'GET' && path === '/pill.js') {
|
|
209
318
|
handleGetPill(res);
|
|
210
319
|
}
|
package/package.json
CHANGED