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/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 VALID_ROLES = new Set(['frontend', 'backend', 'both']);
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: 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;
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
- const answer = await io.ask('Sign in to DocuGate now? [Y/n]', 'y');
178
- doLogin = !/^n(o)?$/i.test(answer);
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
- if (spaces.length === 0 && !connectError) {
211
- io.print(` No spaces found. Create one first: ${BASE_URL}/dashboard/new`);
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 (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
- }
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
- 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}`);
353
+ catch (err) {
354
+ connectError = err.message;
355
+ io.print(` warning ${connectError}`);
231
356
  }
232
357
  }
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})`);
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
- 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) {
389
+ else {
242
390
  try {
243
- await connectFn(spaces[idx].id, repoSlug, repoDocsDir);
244
- io.print(` Connected ${repoSlug} to space "${spaces[idx].name}".`);
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
- else {
252
- io.print(` Skipped connecting to a space.`);
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 answer = await io.ask('Write the tour with IBM Bob? (costs Bobcoins) [y/N]', 'n');
268
- runTour = /^y(es)?$/i.test(answer);
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
- const answer = await io.ask('Add the pill loader to your app? [Y/n]', 'y');
294
- runInstall = !/^n(o)?$/i.test(answer);
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 { join } from 'node:path';
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',
@@ -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 and update, or append.
165
- const idx = tour.stops.findIndex((s) => s.target === stop.target);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docugate",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Command-line tools for DocuGate: set up, check and prepare the documentation DocuGate reads straight from your repository.",
5
5
  "type": "module",
6
6
  "bin": {