docugate 0.1.2 → 0.2.0
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 +184 -47
- package/dist/api.js +61 -0
- package/dist/auth.js +97 -0
- package/dist/check.js +145 -0
- package/dist/cli.js +255 -41
- 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 +225 -0
- package/dist/tour.js +179 -0
- package/package.json +9 -2
- package/pill/dist/pill.js +1042 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
6
|
+
// `docugate tour init` writes the first tour of an app by asking IBM Bob
|
|
7
|
+
// (through Bob Shell) to read the code. Bob does the reading; this file makes
|
|
8
|
+
// sure the result is safe to keep: Bob works in a mode that can only edit tour
|
|
9
|
+
// files, a tour file the user already has is never changed, and every run
|
|
10
|
+
// reports exactly what was added.
|
|
11
|
+
export const TOUR_DIR = '.docugate/tour';
|
|
12
|
+
export const MODE_SLUG = 'tour-writer';
|
|
13
|
+
const MODES_FILE = '.bob/custom_modes.yaml';
|
|
14
|
+
const RULES_FILE = `.bob/rules-${MODE_SLUG}/01-tour-format.md`;
|
|
15
|
+
export const TOUR_RULES = `# DocuGate tour format
|
|
16
|
+
|
|
17
|
+
A tour explains the screens of an app to a developer who is new to its code.
|
|
18
|
+
It lives in \`${TOUR_DIR}/\`, one markdown file per screen.
|
|
19
|
+
|
|
20
|
+
## File
|
|
21
|
+
|
|
22
|
+
- Name the file after the screen's route: \`/invoices/:id\` becomes
|
|
23
|
+
\`invoices-id.md\`, \`/\` becomes \`home.md\`.
|
|
24
|
+
- Front matter holds \`route\` (the URL pattern, with \`:param\` segments) and
|
|
25
|
+
\`title\` (the screen's name).
|
|
26
|
+
- Then one \`##\` section per stop, in the order a person reads the screen.
|
|
27
|
+
|
|
28
|
+
## Stop
|
|
29
|
+
|
|
30
|
+
\`\`\`md
|
|
31
|
+
## Total
|
|
32
|
+
target: [data-tour="invoice-total"]
|
|
33
|
+
data: GET /api/invoices/:id → total
|
|
34
|
+
code: frontend/src/screens/InvoiceDetail.tsx
|
|
35
|
+
source: backend/src/billing.ts
|
|
36
|
+
docs: https://example.com/docs/billing
|
|
37
|
+
|
|
38
|
+
The amount due: the subtotal plus tax, computed by \`totals()\` in the backend.
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
- \`target\`: a CSS selector for the element. Prefer \`[data-tour="..."]\`
|
|
42
|
+
when the element has one. Otherwise use the most stable selector you can
|
|
43
|
+
see, and add a line \`suggest: add data-tour="name" in <file>\`.
|
|
44
|
+
- \`data\`: the request the value comes from and the field in the response,
|
|
45
|
+
as \`METHOD /path → field\`. Leave it out for elements that show no data.
|
|
46
|
+
- \`code\`: the file that renders the element, relative to the repository root.
|
|
47
|
+
- \`source\`: optional. The file where the value is computed or stored, when
|
|
48
|
+
that is somewhere else, such as a backend service.
|
|
49
|
+
- \`docs\`: optional. A link that explains the concept.
|
|
50
|
+
- Then one or two sentences of plain prose: what the element is and why its
|
|
51
|
+
value is what it is.
|
|
52
|
+
|
|
53
|
+
## Rules
|
|
54
|
+
|
|
55
|
+
- Only claim what the code shows. Every path must exist and every endpoint
|
|
56
|
+
must appear in the code. If you are not sure where a value comes from,
|
|
57
|
+
leave \`data\` and \`source\` out rather than guess.
|
|
58
|
+
- Pick the elements a new developer would ask about: numbers, statuses,
|
|
59
|
+
actions and anything whose meaning is not obvious. Not every element.
|
|
60
|
+
- Never edit, rename or delete a tour file that already exists. Only add
|
|
61
|
+
files for screens that have none.
|
|
62
|
+
- Change nothing outside \`${TOUR_DIR}/\`.
|
|
63
|
+
`;
|
|
64
|
+
export const MODE = {
|
|
65
|
+
slug: MODE_SLUG,
|
|
66
|
+
name: '🧭 Tour Writer',
|
|
67
|
+
description: 'Writes DocuGate tour files that explain each screen of an app.',
|
|
68
|
+
roleDefinition: 'You read an application\'s frontend and backend code and write DocuGate tour files: for each screen, which elements matter, what they mean, and where their data really comes from.',
|
|
69
|
+
whenToUse: 'Use this mode to create or extend the tour in .docugate/tour/.',
|
|
70
|
+
customInstructions: `Follow the tour format in .bob/rules-${MODE_SLUG}/. Only add files for screens that have none.`,
|
|
71
|
+
groups: ['read', ['edit', { fileRegex: '\\.docugate/tour/.*\\.md$', description: 'Tour files only' }]],
|
|
72
|
+
};
|
|
73
|
+
export class BobMissingError extends Error {
|
|
74
|
+
}
|
|
75
|
+
/** Bob Shell's headless mode signs in with an API key, not the IDE's IBMid session. */
|
|
76
|
+
export class BobKeyMissingError extends Error {
|
|
77
|
+
}
|
|
78
|
+
const defaultRunBob = (args, cwd) => {
|
|
79
|
+
const result = spawnSync('bob', args, {
|
|
80
|
+
cwd,
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
// `bob` is a .cmd shim on Windows, which only runs through a shell.
|
|
83
|
+
shell: process.platform === 'win32',
|
|
84
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
85
|
+
});
|
|
86
|
+
if (result.error && result.error.code === 'ENOENT') {
|
|
87
|
+
throw new BobMissingError('Bob Shell is not installed.');
|
|
88
|
+
}
|
|
89
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
90
|
+
};
|
|
91
|
+
/** Adds the Tour Writer mode and its rules to the project, keeping whatever is there. */
|
|
92
|
+
export function ensureTourWriter(root) {
|
|
93
|
+
const created = [];
|
|
94
|
+
const modesPath = join(root, MODES_FILE);
|
|
95
|
+
const doc = existsSync(modesPath) ? parseDocument(readFileSync(modesPath, 'utf8')) : parseDocument('customModes: []\n');
|
|
96
|
+
let modes = doc.get('customModes');
|
|
97
|
+
if (!isSeq(modes)) {
|
|
98
|
+
doc.set('customModes', doc.createNode([]));
|
|
99
|
+
modes = doc.get('customModes');
|
|
100
|
+
}
|
|
101
|
+
const seq = modes;
|
|
102
|
+
const present = seq.items.some((item) => isMap(item) && item.get('slug') === MODE_SLUG);
|
|
103
|
+
if (!present) {
|
|
104
|
+
seq.add(doc.createNode(MODE));
|
|
105
|
+
mkdirSync(join(root, '.bob'), { recursive: true });
|
|
106
|
+
writeFileSync(modesPath, doc.toString());
|
|
107
|
+
created.push(MODES_FILE);
|
|
108
|
+
}
|
|
109
|
+
const rulesPath = join(root, RULES_FILE);
|
|
110
|
+
if (!existsSync(rulesPath)) {
|
|
111
|
+
mkdirSync(join(root, `.bob/rules-${MODE_SLUG}`), { recursive: true });
|
|
112
|
+
writeFileSync(rulesPath, TOUR_RULES);
|
|
113
|
+
created.push(RULES_FILE);
|
|
114
|
+
}
|
|
115
|
+
return created;
|
|
116
|
+
}
|
|
117
|
+
function snapshot(root) {
|
|
118
|
+
const dir = join(root, TOUR_DIR);
|
|
119
|
+
const files = new Map();
|
|
120
|
+
if (!existsSync(dir))
|
|
121
|
+
return files;
|
|
122
|
+
for (const name of readdirSync(dir)) {
|
|
123
|
+
if (!name.endsWith('.md'))
|
|
124
|
+
continue;
|
|
125
|
+
const text = readFileSync(join(dir, name), 'utf8');
|
|
126
|
+
files.set(name, { text, hash: createHash('sha256').update(text).digest('hex') });
|
|
127
|
+
}
|
|
128
|
+
return files;
|
|
129
|
+
}
|
|
130
|
+
export function buildPrompt(existing, inlineRules) {
|
|
131
|
+
const have = existing.length
|
|
132
|
+
? `These screens already have a tour file, so leave them exactly as they are: ${existing.join(', ')}.`
|
|
133
|
+
: 'There is no tour yet.';
|
|
134
|
+
return [
|
|
135
|
+
'Read this repository\'s frontend and backend code and write the DocuGate tour:',
|
|
136
|
+
`one file in ${TOUR_DIR}/ for every screen of the app that does not have one yet.`,
|
|
137
|
+
have,
|
|
138
|
+
inlineRules
|
|
139
|
+
? `Follow this format and these rules exactly:\n\n${TOUR_RULES}`
|
|
140
|
+
: `Follow the format and rules in ${RULES_FILE} exactly.`,
|
|
141
|
+
'When you are done, list the files you wrote.',
|
|
142
|
+
].join('\n');
|
|
143
|
+
}
|
|
144
|
+
function parseBobJson(stdout) {
|
|
145
|
+
// --format json prints one object when the session ends; take the last line
|
|
146
|
+
// that parses, in case anything was logged before it.
|
|
147
|
+
for (const line of stdout.trim().split(/\r?\n/).reverse()) {
|
|
148
|
+
try {
|
|
149
|
+
const out = JSON.parse(line);
|
|
150
|
+
const s = out.stats ?? {};
|
|
151
|
+
const cost = typeof s.session_costs === 'number' ? s.session_costs : s.session_costs?.total;
|
|
152
|
+
return {
|
|
153
|
+
stats: { durationMs: s.duration_ms, cost: typeof cost === 'number' ? cost : undefined, taskId: s.task_id },
|
|
154
|
+
lastMessage: typeof out.last_message === 'string' ? out.last_message : undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// not the JSON line
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
// Windows reports a missing command through cmd.exe rather than as ENOENT.
|
|
164
|
+
const isMissing = (run) => run.status === 127 || run.status === 9009 || /not recognized as an internal or external command|command not found/i.test(run.stderr);
|
|
165
|
+
const looksLikeUnknownMode = (run) => /mode/i.test(run.stderr + run.stdout) && run.status !== 0;
|
|
166
|
+
export function tourInit(root, options = {}) {
|
|
167
|
+
const runBob = options.runBob ?? defaultRunBob;
|
|
168
|
+
// Check for Bob before touching the project, so a failed run leaves nothing behind.
|
|
169
|
+
if (isMissing(runBob(['--version'], root)))
|
|
170
|
+
throw new BobMissingError('Bob Shell is not installed.');
|
|
171
|
+
const setup = ensureTourWriter(root);
|
|
172
|
+
mkdirSync(join(root, TOUR_DIR), { recursive: true });
|
|
173
|
+
const before = snapshot(root);
|
|
174
|
+
const common = ['run', '--format', 'json', '--workspace', root, '--max-cost', options.maxCost ?? '3'];
|
|
175
|
+
if (options.teamId)
|
|
176
|
+
common.push('--team-id', options.teamId);
|
|
177
|
+
let mode = MODE_SLUG;
|
|
178
|
+
let run = runBob([...common, '--mode', MODE_SLUG, buildPrompt([...before.keys()], false)], root);
|
|
179
|
+
if (looksLikeUnknownMode(run)) {
|
|
180
|
+
// Bob Shell may not load project modes. Agent mode with the rules in the
|
|
181
|
+
// prompt does the same job; the snapshot below still protects user files.
|
|
182
|
+
mode = 'agent';
|
|
183
|
+
run = runBob([...common, '--mode', 'agent', buildPrompt([...before.keys()], true)], root);
|
|
184
|
+
}
|
|
185
|
+
if (run.status !== 0 && /API key is required|BOB_API_KEY/i.test(run.stderr + run.stdout)) {
|
|
186
|
+
throw new BobKeyMissingError('Bob Shell needs an API key to run on its own.');
|
|
187
|
+
}
|
|
188
|
+
if (run.status !== 0) {
|
|
189
|
+
const detail = (run.stderr || run.stdout).trim().split(/\r?\n/).slice(-5).join('\n');
|
|
190
|
+
throw new Error(`Bob stopped with exit code ${run.status}.${detail ? `\n${detail}` : ''}`);
|
|
191
|
+
}
|
|
192
|
+
// Put back any tour file Bob changed: the user's edits always win.
|
|
193
|
+
const after = snapshot(root);
|
|
194
|
+
const restored = [];
|
|
195
|
+
for (const [name, old] of before) {
|
|
196
|
+
const now = after.get(name);
|
|
197
|
+
if (!now || now.hash !== old.hash) {
|
|
198
|
+
writeFileSync(join(root, TOUR_DIR, name), old.text);
|
|
199
|
+
restored.push(`${TOUR_DIR}/${name}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const added = [...after.keys()].filter((name) => !before.has(name)).map((name) => `${TOUR_DIR}/${name}`);
|
|
203
|
+
return { setup, added, restored, mode, ...parseBobJson(run.stdout) };
|
|
204
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// `docugate tour install` inserts a development-only pill loader into the
|
|
4
|
+
// app's HTML or Next.js layout entry point. The snippet is wrapped in comment
|
|
5
|
+
// markers so it can be found and removed again without touching anything else.
|
|
6
|
+
export const DEFAULT_PORT = 4178;
|
|
7
|
+
// Comment markers used as anchors — one pair for HTML files, one for TSX/JSX.
|
|
8
|
+
const HTML_OPEN = '<!-- docugate:pill -->';
|
|
9
|
+
const HTML_CLOSE = '<!-- /docugate:pill -->';
|
|
10
|
+
const JSX_OPEN = '{/* docugate:pill */}';
|
|
11
|
+
const JSX_CLOSE = '{/* /docugate:pill */}';
|
|
12
|
+
/** Candidate paths checked in order. The first one that exists wins. */
|
|
13
|
+
export const CANDIDATES = [
|
|
14
|
+
'app/layout.tsx',
|
|
15
|
+
'app/layout.jsx',
|
|
16
|
+
'src/app/layout.tsx',
|
|
17
|
+
'src/app/layout.jsx',
|
|
18
|
+
'pages/_document.tsx',
|
|
19
|
+
'pages/_document.jsx',
|
|
20
|
+
'index.html',
|
|
21
|
+
'frontend/index.html',
|
|
22
|
+
'client/index.html',
|
|
23
|
+
'web/index.html',
|
|
24
|
+
'app/index.html',
|
|
25
|
+
'public/index.html',
|
|
26
|
+
];
|
|
27
|
+
/** Returns true if any candidate file already contains the pill snippet. */
|
|
28
|
+
export function hasPillSnippet(root) {
|
|
29
|
+
for (const rel of CANDIDATES) {
|
|
30
|
+
const abs = join(root, rel);
|
|
31
|
+
if (!existsSync(abs))
|
|
32
|
+
continue;
|
|
33
|
+
const text = readFileSync(abs, 'utf8');
|
|
34
|
+
if (text.includes(HTML_OPEN) || text.includes(JSX_OPEN))
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
function isJsx(rel) {
|
|
40
|
+
return rel.endsWith('.tsx') || rel.endsWith('.jsx');
|
|
41
|
+
}
|
|
42
|
+
/** The snippet as lines, without indentation: the file it lands in decides that. */
|
|
43
|
+
function makeSnippet(port, jsx) {
|
|
44
|
+
const url = `http://localhost:${port}/pill.js`;
|
|
45
|
+
if (jsx) {
|
|
46
|
+
return [JSX_OPEN, `{process.env.NODE_ENV === 'development' && <script src="${url}" async />}`, JSX_CLOSE];
|
|
47
|
+
}
|
|
48
|
+
return [
|
|
49
|
+
HTML_OPEN,
|
|
50
|
+
`<script>if(location.hostname==='localhost'||location.hostname==='127.0.0.1'){` +
|
|
51
|
+
`var s=document.createElement('script');s.src='${url}';document.head.appendChild(s)}</script>`,
|
|
52
|
+
HTML_CLOSE,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Inserts the snippet on its own lines just above the closing tag, indented one
|
|
57
|
+
* step deeper than it and with the file's own line endings, so the diff a
|
|
58
|
+
* developer reviews is three clean added lines.
|
|
59
|
+
*/
|
|
60
|
+
function insertSnippet(text, lines, jsx) {
|
|
61
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
62
|
+
let idx = text.lastIndexOf('</body>');
|
|
63
|
+
// _document.tsx may close with </html> instead of </body>
|
|
64
|
+
if (idx === -1 && jsx)
|
|
65
|
+
idx = text.lastIndexOf('</html>');
|
|
66
|
+
if (idx === -1) {
|
|
67
|
+
// Graceful fallback: append to end of file.
|
|
68
|
+
const end = text.endsWith('\n') ? '' : eol;
|
|
69
|
+
return text + end + lines.join(eol) + eol;
|
|
70
|
+
}
|
|
71
|
+
const lineStart = text.lastIndexOf('\n', idx - 1) + 1;
|
|
72
|
+
const indent = text.slice(lineStart, idx);
|
|
73
|
+
if (/^[ \t]*$/.test(indent)) {
|
|
74
|
+
const block = lines.map((line) => `${indent} ${line}`).join(eol) + eol;
|
|
75
|
+
return text.slice(0, lineStart) + block + text.slice(lineStart);
|
|
76
|
+
}
|
|
77
|
+
// The tag shares its line with other markup: keep it on that line.
|
|
78
|
+
return text.slice(0, idx) + lines.join(eol) + eol + text.slice(idx);
|
|
79
|
+
}
|
|
80
|
+
/** Removes the whole marked block, including the lines it sat on. */
|
|
81
|
+
function removeSnippet(text, open, close) {
|
|
82
|
+
const start = text.indexOf(open);
|
|
83
|
+
if (start === -1)
|
|
84
|
+
return text;
|
|
85
|
+
const end = text.indexOf(close, start);
|
|
86
|
+
if (end === -1)
|
|
87
|
+
return text;
|
|
88
|
+
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
|
89
|
+
const from = /^[ \t]*$/.test(text.slice(lineStart, start)) ? lineStart : start;
|
|
90
|
+
let to = end + close.length;
|
|
91
|
+
if (text[to] === '\r')
|
|
92
|
+
to++;
|
|
93
|
+
if (text[to] === '\n')
|
|
94
|
+
to++;
|
|
95
|
+
return text.slice(0, from) + text.slice(to);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Installs (or removes) the dev-only pill loader snippet in the app's entry
|
|
99
|
+
* point. Safe to run multiple times — running it again when the snippet is
|
|
100
|
+
* already present is a no-op.
|
|
101
|
+
*/
|
|
102
|
+
export function tourInstall(root, options = {}) {
|
|
103
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
104
|
+
const remove = options.remove ?? false;
|
|
105
|
+
for (const rel of CANDIDATES) {
|
|
106
|
+
const abs = join(root, rel);
|
|
107
|
+
if (!existsSync(abs))
|
|
108
|
+
continue;
|
|
109
|
+
const jsx = isJsx(rel);
|
|
110
|
+
const open = jsx ? JSX_OPEN : HTML_OPEN;
|
|
111
|
+
const close = jsx ? JSX_CLOSE : HTML_CLOSE;
|
|
112
|
+
const original = readFileSync(abs, 'utf8');
|
|
113
|
+
const hasMarker = original.includes(open);
|
|
114
|
+
if (remove) {
|
|
115
|
+
if (!hasMarker)
|
|
116
|
+
return { target: rel, action: 'already' };
|
|
117
|
+
const updated = removeSnippet(original, open, close);
|
|
118
|
+
writeFileSync(abs, updated);
|
|
119
|
+
return { target: rel, action: 'removed' };
|
|
120
|
+
}
|
|
121
|
+
if (hasMarker)
|
|
122
|
+
return { target: rel, action: 'already' };
|
|
123
|
+
const snippet = makeSnippet(port, jsx);
|
|
124
|
+
const updated = insertSnippet(original, snippet, jsx);
|
|
125
|
+
writeFileSync(abs, updated);
|
|
126
|
+
return { target: rel, action: 'inserted' };
|
|
127
|
+
}
|
|
128
|
+
return { target: null, action: 'none' };
|
|
129
|
+
}
|
|
130
|
+
/** The one-line snippet to paste manually when no entry point was found. */
|
|
131
|
+
export function pillSnippetLine(port) {
|
|
132
|
+
return `<script>if(location.hostname==='localhost'||location.hostname==='127.0.0.1'){var s=document.createElement('script');s.src='http://localhost:${port}/pill.js';document.head.appendChild(s)}</script>`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { loadTours, matchRoute, parseTourFile, routeToFilename, serializeTourFile } from './tour.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Security helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Returns true when the host string is localhost or 127.0.0.1 (any port). */
|
|
10
|
+
function isLocalhost(host) {
|
|
11
|
+
const hostname = host.split(':')[0];
|
|
12
|
+
return hostname === 'localhost' || hostname === '127.0.0.1';
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Check every request for DNS-rebinding safety:
|
|
16
|
+
* - The Host header must be localhost or 127.0.0.1.
|
|
17
|
+
* - If an Origin header is present, it must be a localhost origin.
|
|
18
|
+
* - If an Origin is absent but a Referer is present, the Referer host must be localhost.
|
|
19
|
+
* - Requests with neither Origin nor Referer (e.g. curl) are allowed.
|
|
20
|
+
*
|
|
21
|
+
* Returns true when the request should be accepted.
|
|
22
|
+
*/
|
|
23
|
+
function isAllowed(req) {
|
|
24
|
+
const host = req.headers['host'] ?? '';
|
|
25
|
+
if (host && !isLocalhost(host))
|
|
26
|
+
return false;
|
|
27
|
+
const origin = req.headers['origin'];
|
|
28
|
+
if (origin) {
|
|
29
|
+
try {
|
|
30
|
+
return isLocalhost(new URL(origin).hostname);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const referer = req.headers['referer'];
|
|
37
|
+
if (referer) {
|
|
38
|
+
try {
|
|
39
|
+
return isLocalhost(new URL(referer).hostname);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Response helpers
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
function sendJson(res, status, body) {
|
|
51
|
+
const text = JSON.stringify(body);
|
|
52
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
53
|
+
res.end(text);
|
|
54
|
+
}
|
|
55
|
+
function sendText(res, status, body) {
|
|
56
|
+
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
57
|
+
res.end(body);
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Route title derivation
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
/**
|
|
63
|
+
* Derive a title from a route when no title is provided in the request body.
|
|
64
|
+
* Takes the last non-param segment and title-cases it.
|
|
65
|
+
* /invoices/:id → "Invoices", / → "Home"
|
|
66
|
+
*/
|
|
67
|
+
function titleFromRoute(route) {
|
|
68
|
+
const segments = route.split('/').filter((s) => s && !s.startsWith(':'));
|
|
69
|
+
if (!segments.length)
|
|
70
|
+
return 'Home';
|
|
71
|
+
const last = segments[segments.length - 1];
|
|
72
|
+
return last.charAt(0).toUpperCase() + last.slice(1);
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Request body reader
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
function readBody(req) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const chunks = [];
|
|
80
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
81
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
82
|
+
req.on('error', reject);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Handlers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
function handleGetTour(req, res, root) {
|
|
89
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
90
|
+
const pathname = url.searchParams.get('path') ?? '/';
|
|
91
|
+
const tours = loadTours(root);
|
|
92
|
+
const match = tours.find((t) => matchRoute(t.route, pathname));
|
|
93
|
+
if (!match) {
|
|
94
|
+
sendJson(res, 404, { error: 'not found' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
sendJson(res, 200, { route: match.route, title: match.title, stops: match.stops });
|
|
98
|
+
}
|
|
99
|
+
function handleGetEvents(req, res, root) {
|
|
100
|
+
const tourDir = join(root, '.docugate', 'tour');
|
|
101
|
+
res.writeHead(200, {
|
|
102
|
+
'Content-Type': 'text/event-stream',
|
|
103
|
+
'Cache-Control': 'no-cache',
|
|
104
|
+
Connection: 'keep-alive',
|
|
105
|
+
'Access-Control-Allow-Origin': '*',
|
|
106
|
+
});
|
|
107
|
+
// Send a "connected" event immediately so the client knows the stream is live.
|
|
108
|
+
res.write('data: connected\n\n');
|
|
109
|
+
let closed = false;
|
|
110
|
+
let watcher = null;
|
|
111
|
+
if (existsSync(tourDir)) {
|
|
112
|
+
// Use realpathSync to resolve any 8.3 short names on Windows, which
|
|
113
|
+
// would otherwise trigger a libuv assertion in fs-event.c.
|
|
114
|
+
const watchDir = realpathSync.native(tourDir);
|
|
115
|
+
watcher = watch(watchDir, () => {
|
|
116
|
+
if (!closed)
|
|
117
|
+
res.write('data: change\n\n');
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
req.on('close', () => {
|
|
121
|
+
closed = true;
|
|
122
|
+
// Defer close to avoid calling watcher.close() from within a watcher
|
|
123
|
+
// callback, which can cause a libuv assertion on Windows.
|
|
124
|
+
setImmediate(() => watcher?.close());
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function handlePutStop(req, res, root) {
|
|
128
|
+
let body;
|
|
129
|
+
try {
|
|
130
|
+
const text = await readBody(req);
|
|
131
|
+
body = JSON.parse(text);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
sendJson(res, 400, { error: 'invalid JSON body' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const { route, title: bodyTitle, stop } = body;
|
|
138
|
+
if (!route || !stop) {
|
|
139
|
+
sendJson(res, 400, { error: 'body must have route and stop' });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const tourDir = join(root, '.docugate', 'tour');
|
|
143
|
+
mkdirSync(tourDir, { recursive: true });
|
|
144
|
+
// Prefer an existing file whose route matches, so we preserve files that
|
|
145
|
+
// were named differently from what routeToFilename would derive.
|
|
146
|
+
const existing = loadTours(root).find((t) => t.route === route);
|
|
147
|
+
const filename = existing?.file
|
|
148
|
+
? existing.file.replace(/^\.docugate\/tour\//, '')
|
|
149
|
+
: routeToFilename(route);
|
|
150
|
+
const filepath = join(tourDir, filename);
|
|
151
|
+
const relPath = `.docugate/tour/${filename}`;
|
|
152
|
+
let tour;
|
|
153
|
+
if (existing) {
|
|
154
|
+
const text = readFileSync(filepath, 'utf8');
|
|
155
|
+
tour = parseTourFile(text, relPath);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
tour = {
|
|
159
|
+
route,
|
|
160
|
+
title: bodyTitle ?? titleFromRoute(route),
|
|
161
|
+
stops: [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Upsert: find by target and update, or append.
|
|
165
|
+
const idx = tour.stops.findIndex((s) => s.target === stop.target);
|
|
166
|
+
if (idx >= 0) {
|
|
167
|
+
tour.stops[idx] = stop;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
tour.stops.push(stop);
|
|
171
|
+
}
|
|
172
|
+
writeFileSync(filepath, serializeTourFile(tour));
|
|
173
|
+
sendJson(res, 200, { ok: true });
|
|
174
|
+
}
|
|
175
|
+
function handleGetPill(res) {
|
|
176
|
+
// pill/dist/pill.js is resolved relative to this package's root directory.
|
|
177
|
+
// import.meta.url points at dist/tour-server.js at runtime, so go up one level.
|
|
178
|
+
const pkgRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
179
|
+
const pillPath = join(pkgRoot, 'pill', 'dist', 'pill.js');
|
|
180
|
+
if (!existsSync(pillPath)) {
|
|
181
|
+
sendText(res, 404, 'pill.js not built yet');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const content = readFileSync(pillPath);
|
|
185
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
186
|
+
res.end(content);
|
|
187
|
+
}
|
|
188
|
+
export function startTourServer(root, port) {
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const server = createServer((req, res) => {
|
|
191
|
+
if (!isAllowed(req)) {
|
|
192
|
+
sendText(res, 403, 'Forbidden');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
196
|
+
const path = url.pathname;
|
|
197
|
+
if (req.method === 'GET' && path === '/tour') {
|
|
198
|
+
handleGetTour(req, res, root);
|
|
199
|
+
}
|
|
200
|
+
else if (req.method === 'GET' && path === '/events') {
|
|
201
|
+
handleGetEvents(req, res, root);
|
|
202
|
+
}
|
|
203
|
+
else if (req.method === 'PUT' && path === '/stop') {
|
|
204
|
+
handlePutStop(req, res, root).catch((err) => {
|
|
205
|
+
sendJson(res, 500, { error: err.message });
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
else if (req.method === 'GET' && path === '/pill.js') {
|
|
209
|
+
handleGetPill(res);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
sendText(res, 404, 'Not found');
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
server.listen(port, '127.0.0.1', () => {
|
|
216
|
+
const addr = server.address();
|
|
217
|
+
const boundPort = addr && typeof addr === 'object' ? addr.port : port;
|
|
218
|
+
resolve({
|
|
219
|
+
port: boundPort,
|
|
220
|
+
close: () => server.close(),
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
server.on('error', reject);
|
|
224
|
+
});
|
|
225
|
+
}
|