docugate 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/commands.js +2 -1
- package/dist/menu.js +27 -8
- package/dist/project.d.ts +8 -0
- package/dist/project.js +89 -29
- package/dist/status.d.ts +2 -0
- package/dist/status.js +13 -6
- package/dist/sync/ai.d.ts +10 -0
- package/dist/sync/ai.js +40 -1
- package/dist/sync/ask.d.ts +26 -0
- package/dist/sync/ask.js +55 -0
- package/dist/sync/command.js +54 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -153,13 +153,15 @@ Everything it sets stays editable in the space's settings on [trydocugate.site](
|
|
|
153
153
|
|
|
154
154
|
Documentation goes stale because updating it is a chore nobody owns. `docugate sync` does the chore: it reads what changed in the code, finds the pages those changes made wrong, asks an AI model to correct them, and gives you the edits to review.
|
|
155
155
|
|
|
156
|
+
The easy way is the menu: run `docugate` and pick **Update the docs with recent changes**. It asks which changes to catch up on (since the last update, today, this week, this month, or since a commit you pick), shows the pages they touch, then writes the edits into your files or a pull request. The first time, it asks for an AI key and keeps it on your computer. That is `docugate sync --ask`. The commands below are the same thing for scripts, agents and CI.
|
|
157
|
+
|
|
156
158
|
```sh
|
|
157
159
|
npx docugate sync --dry-run # free and instant: which pages are probably out of date
|
|
158
160
|
npx docugate sync # write the edits into your docs folder
|
|
159
161
|
npx docugate sync --pr # push them and open a pull request instead
|
|
160
162
|
```
|
|
161
163
|
|
|
162
|
-
`sync` needs an AI key of your own, in `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (set `DOCUGATE_AI_BASE_URL` to use another OpenAI-compatible service). It talks to the provider directly: nothing goes through DocuGate's servers, and it works without a DocuGate account.
|
|
164
|
+
`sync` needs an AI key of your own, in `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (set `DOCUGATE_AI_BASE_URL` to use another OpenAI-compatible service), or the one you pasted in the menu, kept in `~/.docugate/ai.json`. It talks to the provider directly: nothing goes through DocuGate's servers, and it works without a DocuGate account.
|
|
163
165
|
|
|
164
166
|
**What it does, step by step**
|
|
165
167
|
|
|
@@ -351,7 +353,7 @@ Pin the version in CI, as above. Without a pin, the rules being enforced can cha
|
|
|
351
353
|
| --- | --- | --- |
|
|
352
354
|
| `docugate` | | In a terminal: the menu. Elsewhere: the help. |
|
|
353
355
|
| `docugate status` | `--json` | Where things stand: account, docs, sync and tour, and what to do next. |
|
|
354
|
-
| `docugate sync` | `--dry-run`, `--since <when>`, `--pr`, `--yes`, `--max-files <n>`, `--model <name>`, `--quiet` | Update the docs from what changed in the code. |
|
|
356
|
+
| `docugate sync` | `--ask`, `--dry-run`, `--since <when>`, `--pr`, `--yes`, `--max-files <n>`, `--model <name>`, `--quiet` | Update the docs from what changed in the code. |
|
|
355
357
|
| `docugate sync init` | `--freshness push\|daily\|weekly\|off`, `--force` | Add the GitHub Action that runs sync on a schedule or on every push. |
|
|
356
358
|
| `docugate sync hook install` | | Tell you after each commit which pages the code left behind. |
|
|
357
359
|
| `docugate sync hook remove` | | Remove that hook. |
|
package/dist/commands.js
CHANGED
|
@@ -14,9 +14,10 @@ export const COMMANDS = [
|
|
|
14
14
|
{ name: 'max-files', value: 'n', description: 'Most pages one run may change (default: 10)' },
|
|
15
15
|
{ name: 'model', value: 'name', description: 'Model to use (default: sync.model in docugate.json)' },
|
|
16
16
|
{ name: 'quiet', description: 'Print one line only when pages are out of date (for the git hook)' },
|
|
17
|
+
{ name: 'ask', description: 'Ask which changes, show the pages first, then ask where the edits go (what the menu runs)' },
|
|
17
18
|
YES,
|
|
18
19
|
],
|
|
19
|
-
examples: ['docugate sync --dry-run', 'docugate sync --since 7d', 'docugate sync --pr --yes'],
|
|
20
|
+
examples: ['docugate sync --dry-run', 'docugate sync --since 7d', 'docugate sync --pr --yes', 'docugate sync --ask'],
|
|
20
21
|
account: 'none',
|
|
21
22
|
},
|
|
22
23
|
{
|
package/dist/menu.js
CHANGED
|
@@ -20,6 +20,24 @@ export function buildChoices(s, now = new Date()) {
|
|
|
20
20
|
keywords: 'login sign in account github',
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
|
+
if (signedIn && p.git) {
|
|
24
|
+
choices.push({
|
|
25
|
+
value: ['connect'],
|
|
26
|
+
label: p.connected ? 'Connect again, or to another project' : 'Connect this folder to a project',
|
|
27
|
+
status: p.connected ? 'connected' : 'not connected',
|
|
28
|
+
description: 'Links this codebase to a project on DocuGate: its docs, and a backend or frontend beside it. The dashboard then shows its packages, security and updates.',
|
|
29
|
+
keywords: 'connect link project codebase frontend backend',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (p.connected) {
|
|
33
|
+
choices.push({
|
|
34
|
+
value: ['audit'],
|
|
35
|
+
label: 'Check packages for vulnerabilities',
|
|
36
|
+
status: '',
|
|
37
|
+
description: 'Reads your lockfiles and checks every package against the published advisories. The results show in the dashboard under Security.',
|
|
38
|
+
keywords: 'audit security packages vulnerabilities osv',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
23
41
|
choices.push({
|
|
24
42
|
value: ['developers'],
|
|
25
43
|
label: 'Set up a developer space',
|
|
@@ -33,10 +51,10 @@ export function buildChoices(s, now = new Date()) {
|
|
|
33
51
|
description: 'A short guided setup: where the docs live, the kind of repository, and linking a DocuGate space. It never overwrites your files.',
|
|
34
52
|
keywords: 'init setup configure',
|
|
35
53
|
}, {
|
|
36
|
-
value: ['sync'],
|
|
37
|
-
label: 'Update the docs
|
|
54
|
+
value: ['sync', '--ask'],
|
|
55
|
+
label: 'Update the docs with recent changes',
|
|
38
56
|
status: y.commits === null ? '' : y.commits === 0 ? 'up to date' : `${plural(y.commits, 'commit')} behind`,
|
|
39
|
-
description: '
|
|
57
|
+
description: 'Pick which changes (since the last update, today, this week), see the pages they touch, then write the edits into the files or a pull request. Uses your own AI key.',
|
|
40
58
|
keywords: 'sync update docs changes ai',
|
|
41
59
|
disabled: needsDocs,
|
|
42
60
|
}, {
|
|
@@ -48,7 +66,7 @@ export function buildChoices(s, now = new Date()) {
|
|
|
48
66
|
disabled: needsDocs,
|
|
49
67
|
}, {
|
|
50
68
|
value: ['sync', 'init'],
|
|
51
|
-
label: '
|
|
69
|
+
label: 'Update the docs automatically on GitHub',
|
|
52
70
|
status: y.workflow ? `on · ${p.freshness}` : 'not set up',
|
|
53
71
|
description: `Adds a GitHub Action that runs sync ${p.freshness === 'push' ? 'on every push' : p.freshness === 'daily' ? 'every day' : 'every week'} and opens a pull request with the edits. You review and merge.`,
|
|
54
72
|
keywords: 'sync init github action workflow schedule freshness',
|
|
@@ -156,16 +174,17 @@ export function buildMenu(s, section) {
|
|
|
156
174
|
if (section === 'more') {
|
|
157
175
|
const hook = all.find((c) => c.value[0] === 'sync' && c.value[1] === 'hook');
|
|
158
176
|
return [
|
|
159
|
-
...pick('sync --dry-run', 'sync init', ...(hook ? [hook.value.join(' ')] : [])
|
|
177
|
+
...pick('developers', ...(s.project.setUp ? ['init'] : []), ...(s.project.connected ? ['connect'] : []), 'sync --dry-run', 'sync init', ...(hook ? [hook.value.join(' ')] : [])),
|
|
178
|
+
{ value: [...GO.tours], label: 'Tours' },
|
|
179
|
+
...pick('status', 'openapi', 'help', 'logout'),
|
|
160
180
|
{ value: [...GO.back], label: 'Back' },
|
|
161
181
|
];
|
|
162
182
|
}
|
|
183
|
+
// The few things people come for. Everything else is one step away, under More.
|
|
163
184
|
const signedIn = s.account.state === 'signed-in';
|
|
164
185
|
return [
|
|
165
|
-
...pick('
|
|
166
|
-
{ value: [...GO.tours], label: 'Tours' },
|
|
186
|
+
...pick(...(signedIn ? [] : ['login']), ...(s.project.connected ? [] : ['connect']), ...(s.project.setUp ? [] : ['init']), 'sync --ask', 'audit', 'check'),
|
|
167
187
|
{ value: [...GO.more], label: 'More' },
|
|
168
|
-
...pick(signedIn ? 'logout' : 'login'),
|
|
169
188
|
{ value: [...GO.quit], label: 'Quit' },
|
|
170
189
|
];
|
|
171
190
|
}
|
package/dist/project.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Choice } from './select.js';
|
|
1
2
|
export declare const PROJECT_TYPES: readonly ["frontend", "backend", "serverless", "monorepo"];
|
|
2
3
|
export type ProjectType = (typeof PROJECT_TYPES)[number];
|
|
3
4
|
export declare const FREQUENCIES: readonly ["push", "daily", "weekly", "off"];
|
|
@@ -110,6 +111,13 @@ export declare function sectionFor(type: ProjectType, sources: {
|
|
|
110
111
|
}[]): string;
|
|
111
112
|
/** The project someone named: by slug, `owner/slug` or name, in any case. */
|
|
112
113
|
export declare function pickProject(choices: ProjectChoice[], wanted: string): ProjectChoice | undefined;
|
|
114
|
+
/**
|
|
115
|
+
* One choice from a list: the arrow-key picker in a terminal that can draw it,
|
|
116
|
+
* else a typed answer. Null when the person pressed escape.
|
|
117
|
+
*/
|
|
118
|
+
export declare function pickOne<T extends string>(title: string, choices: Choice<T>[], fallback: T, ask: (q: string) => Promise<string>): Promise<T | null>;
|
|
119
|
+
/** A typed answer. Each question has its own reader, so the picker owns the keyboard in between. */
|
|
120
|
+
export declare function askLine(question: string): Promise<string>;
|
|
113
121
|
/**
|
|
114
122
|
* `docugate connect`, run in each codebase of a project: the backend, then the
|
|
115
123
|
* frontend, whether they are two repositories or two folders of one. It joins
|
package/dist/project.js
CHANGED
|
@@ -10,6 +10,7 @@ import { guessRole } from './init.js';
|
|
|
10
10
|
import { scanLockfiles } from './lockfiles.js';
|
|
11
11
|
import { defaultGit } from './sync/changes.js';
|
|
12
12
|
import { repoFromRemote } from './sync/pr.js';
|
|
13
|
+
import { canPick, select } from './select.js';
|
|
13
14
|
import { bold, dim, green, plural, red, yellow } from './ui.js';
|
|
14
15
|
// A repository connected to a DocuGate account. `docugate connect` links it, and
|
|
15
16
|
// from then on the dashboard decides which docs it updates, how often and what
|
|
@@ -60,9 +61,12 @@ export function detectType(root) {
|
|
|
60
61
|
const role = guessRole(root);
|
|
61
62
|
if (workspaces || role === 'both')
|
|
62
63
|
return 'monorepo';
|
|
64
|
+
// Checked before the hosting files: a frontend on Vercel or Netlify has one too.
|
|
65
|
+
if (role === 'frontend')
|
|
66
|
+
return 'frontend';
|
|
63
67
|
if (['serverless.yml', 'serverless.ts', 'wrangler.toml', 'wrangler.jsonc', 'netlify.toml', 'vercel.json', 'template.yaml'].some(has))
|
|
64
68
|
return 'serverless';
|
|
65
|
-
return
|
|
69
|
+
return 'backend';
|
|
66
70
|
}
|
|
67
71
|
/** Where the backend lives in a monorepo, which is where the key-check package installs. */
|
|
68
72
|
export function detectBackendDir(root) {
|
|
@@ -206,9 +210,39 @@ export function pickProject(choices, wanted) {
|
|
|
206
210
|
const w = wanted.trim().toLowerCase();
|
|
207
211
|
return choices.find((p) => `${p.owner}/${p.slug}`.toLowerCase() === w) ?? choices.find((p) => p.slug.toLowerCase() === w) ?? choices.find((p) => p.name.toLowerCase() === w);
|
|
208
212
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
/** What each kind of codebase and each cadence means, for the picker. */
|
|
214
|
+
const TYPE_HINT = {
|
|
215
|
+
frontend: 'What people see and click: a web or mobile app',
|
|
216
|
+
backend: 'An API or server the frontend calls',
|
|
217
|
+
serverless: 'Functions on Lambda, Workers or similar, with no server of their own',
|
|
218
|
+
monorepo: 'A frontend and a backend in this one folder',
|
|
219
|
+
};
|
|
220
|
+
const FREQUENCY_HINT = {
|
|
221
|
+
push: 'On every push to the default branch',
|
|
222
|
+
daily: 'Once a day',
|
|
223
|
+
weekly: 'Once a week',
|
|
224
|
+
off: 'Only when you run docugate sync',
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* One choice from a list: the arrow-key picker in a terminal that can draw it,
|
|
228
|
+
* else a typed answer. Null when the person pressed escape.
|
|
229
|
+
*/
|
|
230
|
+
export async function pickOne(title, choices, fallback, ask) {
|
|
231
|
+
if (canPick())
|
|
232
|
+
return select({ title, choices, rows: Math.min(choices.length, 10), legend: false });
|
|
233
|
+
const values = choices.map((c) => c.value);
|
|
234
|
+
const answer = (await ask(` ${title} ${dim(`(${values.join(', ')})`)} [${fallback}] `)).trim().toLowerCase();
|
|
235
|
+
return values.includes(answer) ? answer : fallback;
|
|
236
|
+
}
|
|
237
|
+
/** A typed answer. Each question has its own reader, so the picker owns the keyboard in between. */
|
|
238
|
+
export async function askLine(question) {
|
|
239
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
240
|
+
try {
|
|
241
|
+
return await rl.question(question);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
rl.close();
|
|
245
|
+
}
|
|
212
246
|
}
|
|
213
247
|
const codeOf = (error) => error.code;
|
|
214
248
|
/**
|
|
@@ -228,20 +262,17 @@ export async function connectCommand(root, args) {
|
|
|
228
262
|
}
|
|
229
263
|
const { repo, path } = codebaseOf(root);
|
|
230
264
|
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI && !args.yes;
|
|
231
|
-
const
|
|
232
|
-
const ask = async (q) => (rl ? rl.question(q) : '');
|
|
265
|
+
const ask = async (q) => (interactive ? askLine(q) : '');
|
|
233
266
|
const where = path ? `${repo} ${dim(`(${path}/)`)}` : repo;
|
|
234
267
|
try {
|
|
235
268
|
// Signed in first: everything after this is done as someone.
|
|
236
269
|
const { user } = (await (await apiFetch('/api/auth/session')).json().catch(() => ({ user: null })));
|
|
237
270
|
if (!user) {
|
|
238
|
-
if (!
|
|
271
|
+
if (!interactive || !/^(|y|yes)$/i.test((await ask(` Sign in to DocuGate first? ${dim('(Y/n)')} `)).trim())) {
|
|
239
272
|
console.error(`${red('error')} Not signed in. Run docugate login, then docugate connect again.`);
|
|
240
273
|
return 1;
|
|
241
274
|
}
|
|
242
|
-
rl.pause();
|
|
243
275
|
await login(base());
|
|
244
|
-
rl.resume();
|
|
245
276
|
}
|
|
246
277
|
const { projects: choices } = (await (await apiFetch('/api/projects/choices')).json());
|
|
247
278
|
console.log();
|
|
@@ -261,15 +292,28 @@ export async function connectCommand(root, args) {
|
|
|
261
292
|
return 1;
|
|
262
293
|
}
|
|
263
294
|
}
|
|
264
|
-
else if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
295
|
+
else if (interactive) {
|
|
296
|
+
// The codebases already in a project say what it is better than its name does.
|
|
297
|
+
const options = [
|
|
298
|
+
...choices.map((p) => {
|
|
299
|
+
const here = p.codebases.some((c) => c.repo.toLowerCase() === repo.toLowerCase() && c.path === path);
|
|
300
|
+
const parts = p.codebases.map((c) => `${c.type} ${c.repo.split('/').at(-1)}${c.path ? `/${c.path}` : ''}`);
|
|
301
|
+
return {
|
|
302
|
+
value: p.id,
|
|
303
|
+
label: p.name,
|
|
304
|
+
status: [p.org, here ? 'already has this codebase' : ''].filter(Boolean).join(' · '),
|
|
305
|
+
description: parts.length ? `Fed by ${parts.join(', ')}` : 'No codebase yet',
|
|
306
|
+
keywords: `${p.owner}/${p.slug} ${parts.join(' ')}`,
|
|
307
|
+
recommended: here,
|
|
308
|
+
};
|
|
309
|
+
}),
|
|
310
|
+
{ value: 'new', label: 'A new project', description: hasDocs ? `Its docs come from ${docsDir}/ in this codebase` : `Needs a docs folder here first (${config.docsDir}/). Run docugate init`, disabled: hasDocs ? undefined : 'no docs folder here yet' },
|
|
311
|
+
{ value: 'none', label: 'Not yet', description: 'Connect it now and choose its project later, in the dashboard' },
|
|
312
|
+
];
|
|
313
|
+
const answer = await pickOne('Which project is this codebase part of?', options, 'none', ask);
|
|
314
|
+
if (answer === null)
|
|
315
|
+
return 1;
|
|
316
|
+
if (answer === 'new') {
|
|
273
317
|
if (!hasDocs) {
|
|
274
318
|
console.log(` ${yellow('No docs folder here yet')} (${config.docsDir}/). Run docugate init in this folder to start one, then connect again.`);
|
|
275
319
|
return 1;
|
|
@@ -281,19 +325,30 @@ export async function connectCommand(root, args) {
|
|
|
281
325
|
console.log(` ${green('✓')} Created ${space.name}, published from ${docsDir}/.`);
|
|
282
326
|
}
|
|
283
327
|
else {
|
|
284
|
-
project = choices
|
|
328
|
+
project = choices.find((p) => p.id === answer);
|
|
285
329
|
}
|
|
286
330
|
}
|
|
287
331
|
else if (choices.length === 1) {
|
|
288
332
|
project = choices[0];
|
|
289
333
|
}
|
|
290
|
-
const
|
|
334
|
+
const guessed = detectType(root);
|
|
335
|
+
const type = args.type ??
|
|
336
|
+
(interactive
|
|
337
|
+
? await pickOne('What is this codebase?', PROJECT_TYPES.map((t) => ({ value: t, label: t, description: TYPE_HINT[t], recommended: t === guessed, status: t === guessed ? 'detected' : undefined })), guessed, ask)
|
|
338
|
+
: guessed);
|
|
339
|
+
if (type === null)
|
|
340
|
+
return 1;
|
|
291
341
|
let backendDir = args.backendDir ?? '';
|
|
292
342
|
if (type === 'monorepo' && !backendDir) {
|
|
293
343
|
const found = detectBackendDir(root);
|
|
294
344
|
backendDir = (await ask(` Which folder holds the backend? [${found || 'none'}] `)).trim() || found;
|
|
295
345
|
}
|
|
296
|
-
const frequency = args.frequency ??
|
|
346
|
+
const frequency = args.frequency ??
|
|
347
|
+
(interactive
|
|
348
|
+
? await pickOne('How often should its docs be checked?', FREQUENCIES.map((f) => ({ value: f, label: f, description: FREQUENCY_HINT[f], recommended: f === 'weekly' })), 'weekly', ask)
|
|
349
|
+
: 'weekly');
|
|
350
|
+
if (frequency === null)
|
|
351
|
+
return 1;
|
|
297
352
|
const res = await apiFetch('/api/projects', {
|
|
298
353
|
method: 'POST',
|
|
299
354
|
body: JSON.stringify({ repo, path, type, backendDir, frequency, spaceId: project?.id ?? null, audit: !args.noAudit }),
|
|
@@ -320,7 +375,7 @@ export async function connectCommand(root, args) {
|
|
|
320
375
|
// Two pages at one address: a section of its own settles it.
|
|
321
376
|
if (code === 'source_collision' && attempt === 0) {
|
|
322
377
|
const fallback = section || (type === 'monorepo' ? 'app' : type);
|
|
323
|
-
section =
|
|
378
|
+
section = interactive ? (await ask(` Some of its pages share an address with pages already there. Put them under a section? [${fallback}] `)).trim() || fallback : fallback;
|
|
324
379
|
continue;
|
|
325
380
|
}
|
|
326
381
|
section = null;
|
|
@@ -346,16 +401,24 @@ export async function connectCommand(root, args) {
|
|
|
346
401
|
console.log(` ${green('Connected')} ${where}. Choose its project in the dashboard, under Projects, or run docugate connect again.`);
|
|
347
402
|
}
|
|
348
403
|
console.log();
|
|
404
|
+
// The first check now, so the dashboard's Security column is filled from the start.
|
|
405
|
+
// After this, each docugate sync checks again, and docugate audit does on demand.
|
|
406
|
+
if (!args.noAudit) {
|
|
407
|
+
const answer = await runAudit(root, token);
|
|
408
|
+
console.log(` ${bold('Dependency check')} ${dim(`${plural(answer.files.length, 'lockfile')} read`)}`);
|
|
409
|
+
printFindings(answer);
|
|
410
|
+
console.log();
|
|
411
|
+
}
|
|
349
412
|
if (type === 'frontend')
|
|
350
413
|
console.log(` ${dim('A frontend needs no server package.')}`);
|
|
351
414
|
else {
|
|
352
415
|
const inside = type === 'monorepo' && backendDir ? `in ${backendDir}` : 'in your backend';
|
|
353
|
-
console.log(` ${dim(`
|
|
416
|
+
console.log(` ${dim(`Only if you use DocuGate API keys: install the package ${inside} with`)} npm i docugate`);
|
|
354
417
|
}
|
|
355
418
|
console.log();
|
|
356
|
-
|
|
357
|
-
console.log(`
|
|
358
|
-
console.log(` ${dim('
|
|
419
|
+
// Nothing else to set up: updates run from this computer, and report to the dashboard on their own.
|
|
420
|
+
console.log(` ${green('Done.')} Whenever you want this codebase's changes in the docs, run ${bold('docugate')} and pick ${bold('Update the docs')}.`);
|
|
421
|
+
console.log(` ${dim('To have GitHub do it on every push instead: docugate, then More, then Update the docs automatically on GitHub.')}`);
|
|
359
422
|
console.log();
|
|
360
423
|
return 0;
|
|
361
424
|
}
|
|
@@ -363,7 +426,4 @@ export async function connectCommand(root, args) {
|
|
|
363
426
|
console.error(`${red('error')} ${error.message}`);
|
|
364
427
|
return 1;
|
|
365
428
|
}
|
|
366
|
-
finally {
|
|
367
|
-
rl?.close();
|
|
368
|
-
}
|
|
369
429
|
}
|
package/dist/status.d.ts
CHANGED
package/dist/status.js
CHANGED
|
@@ -2,6 +2,8 @@ import { loadConfig } from './config.js';
|
|
|
2
2
|
import { accountState } from './session.js';
|
|
3
3
|
import { collectChanges, defaultGit } from './sync/changes.js';
|
|
4
4
|
import { hookInstalled } from './sync/hook.js';
|
|
5
|
+
import { withSavedKey } from './sync/ai.js';
|
|
6
|
+
import { projectToken } from './project.js';
|
|
5
7
|
import { loadPages, scoreImpact } from './sync/impact.js';
|
|
6
8
|
import { readSyncState } from './sync/state.js';
|
|
7
9
|
import { workflowInstalled } from './sync/workflow.js';
|
|
@@ -66,6 +68,7 @@ export async function gatherStatus(root, deps = {}) {
|
|
|
66
68
|
project: {
|
|
67
69
|
git: inGit,
|
|
68
70
|
setUp: loaded.found,
|
|
71
|
+
connected: Boolean(projectToken(root, env)),
|
|
69
72
|
docsDir,
|
|
70
73
|
pages: pages.length,
|
|
71
74
|
role: loaded.config.role,
|
|
@@ -75,7 +78,10 @@ export async function gatherStatus(root, deps = {}) {
|
|
|
75
78
|
sync: {
|
|
76
79
|
workflow: workflowInstalled(root),
|
|
77
80
|
hook: inGit && hookInstalled(root, git),
|
|
78
|
-
key:
|
|
81
|
+
key: (() => {
|
|
82
|
+
const withKey = withSavedKey(env);
|
|
83
|
+
return withKey.ANTHROPIC_API_KEY ? 'anthropic' : withKey.OPENAI_API_KEY ? 'openai' : null;
|
|
84
|
+
})(),
|
|
79
85
|
lastRunAt: state.lastRunAt,
|
|
80
86
|
commits,
|
|
81
87
|
affected,
|
|
@@ -90,14 +96,15 @@ export function nextStep(s) {
|
|
|
90
96
|
if (s.account.state === 'signed-out') {
|
|
91
97
|
return { command: 'login', why: 'Connect your DocuGate account so your docs can be published' };
|
|
92
98
|
}
|
|
99
|
+
// Connected first: it is what makes the dashboard see this codebase, its packages and its runs.
|
|
100
|
+
if (s.account.state === 'signed-in' && s.project.git && !s.project.connected) {
|
|
101
|
+
return { command: 'connect', why: 'Link this folder to a project, so the dashboard sees it' };
|
|
102
|
+
}
|
|
93
103
|
if (!s.project.setUp)
|
|
94
104
|
return { command: 'init', why: 'Set this repository up for DocuGate' };
|
|
95
|
-
if (s.project.git && s.project.freshness !== 'off' && !s.sync.workflow) {
|
|
96
|
-
return { command: 'sync init', why: `Keep the docs current automatically (${s.project.freshness})` };
|
|
97
|
-
}
|
|
98
105
|
if (s.project.git && s.sync.commits && s.sync.commits > 0) {
|
|
99
106
|
return {
|
|
100
|
-
command: 'sync',
|
|
107
|
+
command: 'sync --ask',
|
|
101
108
|
why: s.sync.affected
|
|
102
109
|
? `${plural(s.sync.affected, 'page')} may be out of date after ${plural(s.sync.commits, 'commit')}`
|
|
103
110
|
: `${plural(s.sync.commits, 'commit')} since the last sync`,
|
|
@@ -148,7 +155,7 @@ export function renderStatus(s, now = new Date()) {
|
|
|
148
155
|
p.freshness === 'off' ? 'automatic sync off' : `checks ${p.freshness}`,
|
|
149
156
|
y.workflow ? 'workflow installed' : 'no workflow',
|
|
150
157
|
y.hook ? 'hook on' : null,
|
|
151
|
-
y.key ? `${y.key === 'anthropic' ? 'Anthropic' : 'OpenAI'} key found` : 'no AI key
|
|
158
|
+
y.key ? `${y.key === 'anthropic' ? 'Anthropic' : 'OpenAI'} key found` : 'no AI key yet',
|
|
152
159
|
].filter(Boolean);
|
|
153
160
|
lines.push(row('Sync', y.workflow ? ok : off, `${bits.join(dim(' · '))}`));
|
|
154
161
|
const behind = y.commits === null
|
package/dist/sync/ai.d.ts
CHANGED
|
@@ -26,6 +26,16 @@ export type AiRequest = {
|
|
|
26
26
|
export type AiFn = (request: AiRequest) => Promise<string>;
|
|
27
27
|
/** Which provider and key to use: the configured one, else whichever key is set. */
|
|
28
28
|
export declare function resolveProvider(env: NodeJS.ProcessEnv, sync?: SyncConfig, modelOverride?: string): Provider | null;
|
|
29
|
+
export type SavedKey = {
|
|
30
|
+
provider: 'anthropic' | 'openai';
|
|
31
|
+
key: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function readSavedKey(): SavedKey | null;
|
|
34
|
+
export declare function saveKey(saved: SavedKey): void;
|
|
35
|
+
/** Which provider a pasted key belongs to, from its shape. */
|
|
36
|
+
export declare function providerOfKey(key: string): SavedKey['provider'];
|
|
37
|
+
/** The environment, with the saved key filled in when it has none of its own. */
|
|
38
|
+
export declare function withSavedKey(env: NodeJS.ProcessEnv, saved?: SavedKey | null): NodeJS.ProcessEnv;
|
|
29
39
|
export declare const KEY_HELP: string;
|
|
30
40
|
/** The call to a real provider. */
|
|
31
41
|
export declare function providerAiFn(provider: Provider): AiFn;
|
package/dist/sync/ai.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
2
5
|
import { SyncError } from './changes.js';
|
|
3
6
|
const DEFAULT_MODELS = { anthropic: 'claude-sonnet-5-5', openai: 'gpt-4o' };
|
|
4
7
|
/** Which provider and key to use: the configured one, else whichever key is set. */
|
|
@@ -20,8 +23,44 @@ export function resolveProvider(env, sync = {}, modelOverride) {
|
|
|
20
23
|
: (env.DOCUGATE_AI_BASE_URL ?? env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1'),
|
|
21
24
|
};
|
|
22
25
|
}
|
|
26
|
+
/*
|
|
27
|
+
* A key pasted once in the menu, kept on this computer next to the sign-in, so
|
|
28
|
+
* nobody has to export it in every terminal. The environment still wins: CI
|
|
29
|
+
* and a developer's own shell setup are never overridden.
|
|
30
|
+
*/
|
|
31
|
+
const KEY_FILE = () => join(process.env.DOCUGATE_HOME ?? join(homedir(), '.docugate'), 'ai.json');
|
|
32
|
+
export function readSavedKey() {
|
|
33
|
+
try {
|
|
34
|
+
const saved = JSON.parse(readFileSync(KEY_FILE(), 'utf8'));
|
|
35
|
+
return (saved.provider === 'anthropic' || saved.provider === 'openai') && typeof saved.key === 'string' && saved.key ? saved : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function saveKey(saved) {
|
|
42
|
+
const file = KEY_FILE();
|
|
43
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
44
|
+
writeFileSync(file, JSON.stringify(saved), { mode: 0o600 });
|
|
45
|
+
try {
|
|
46
|
+
chmodSync(file, 0o600);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Windows keeps its own permissions.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Which provider a pasted key belongs to, from its shape. */
|
|
53
|
+
export function providerOfKey(key) {
|
|
54
|
+
return key.trim().startsWith('sk-ant-') ? 'anthropic' : 'openai';
|
|
55
|
+
}
|
|
56
|
+
/** The environment, with the saved key filled in when it has none of its own. */
|
|
57
|
+
export function withSavedKey(env, saved = readSavedKey()) {
|
|
58
|
+
if (env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || !saved)
|
|
59
|
+
return env;
|
|
60
|
+
return { ...env, [saved.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY']: saved.key };
|
|
61
|
+
}
|
|
23
62
|
export const KEY_HELP = 'Set ANTHROPIC_API_KEY (or OPENAI_API_KEY, optionally with DOCUGATE_AI_BASE_URL for another OpenAI-compatible service) ' +
|
|
24
|
-
'in your environment, or as a secret in GitHub Actions.';
|
|
63
|
+
'in your environment, or as a secret in GitHub Actions. Or run `docugate`, pick Update the docs, and paste a key once.';
|
|
25
64
|
/** The call to a real provider. */
|
|
26
65
|
export function providerAiFn(provider) {
|
|
27
66
|
return async ({ system, user, maxTokens }) => {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Choice } from '../select.js';
|
|
2
|
+
import type { GitFn } from './changes.js';
|
|
3
|
+
import type { SavedKey } from './ai.js';
|
|
4
|
+
export type Since = 'last' | '1d' | '7d' | '30d' | 'commit';
|
|
5
|
+
export type Destination = 'files' | 'pr' | 'stop';
|
|
6
|
+
export type AskDeps = {
|
|
7
|
+
/** One arrow-key question. Tests answer it themselves. */
|
|
8
|
+
pick: <T extends string>(title: string, choices: Choice<T>[], fallback: T) => Promise<T | null>;
|
|
9
|
+
/** A typed answer, for the AI key. */
|
|
10
|
+
line: (question: string) => Promise<string>;
|
|
11
|
+
git?: GitFn;
|
|
12
|
+
now?: Date;
|
|
13
|
+
save?: (key: SavedKey) => void;
|
|
14
|
+
};
|
|
15
|
+
/** Which changes to read. Null when the person backed out. */
|
|
16
|
+
export declare function askSince(root: string, lastRunAt: string | undefined, deps: AskDeps): Promise<string | null>;
|
|
17
|
+
/** The latest commits, newest first, for picking a starting point. */
|
|
18
|
+
export declare function recentCommits(root: string, git: GitFn, count?: number): {
|
|
19
|
+
sha: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
when: string;
|
|
22
|
+
}[];
|
|
23
|
+
/** Where the edits go, after the preview. */
|
|
24
|
+
export declare function askDestination(pages: number, canOpenPr: boolean, deps: AskDeps): Promise<Destination | null>;
|
|
25
|
+
/** Asks for an AI key once and keeps it on this computer. Null when none was given. */
|
|
26
|
+
export declare function askForKey(deps: AskDeps): Promise<SavedKey | null>;
|
package/dist/sync/ask.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ago } from '../status.js';
|
|
2
|
+
import { dim } from '../ui.js';
|
|
3
|
+
import { defaultGit } from './changes.js';
|
|
4
|
+
import { providerOfKey, saveKey } from './ai.js';
|
|
5
|
+
/** Which changes to read. Null when the person backed out. */
|
|
6
|
+
export async function askSince(root, lastRunAt, deps) {
|
|
7
|
+
const since = await deps.pick('Which changes should the docs catch up on?', [
|
|
8
|
+
{ value: 'last', label: 'Since the last update', status: lastRunAt ? ago(lastRunAt, deps.now) : 'none yet', recommended: true },
|
|
9
|
+
{ value: '1d', label: 'Today' },
|
|
10
|
+
{ value: '7d', label: 'This week' },
|
|
11
|
+
{ value: '30d', label: 'This month' },
|
|
12
|
+
{ value: 'commit', label: 'Since a commit I pick' },
|
|
13
|
+
], 'last');
|
|
14
|
+
if (since !== 'commit')
|
|
15
|
+
return since;
|
|
16
|
+
const commits = recentCommits(root, deps.git ?? defaultGit);
|
|
17
|
+
if (!commits.length)
|
|
18
|
+
return 'last';
|
|
19
|
+
return deps.pick('Since which commit? Everything after it is read.', commits.map((c, i) => ({ value: c.sha, label: c.subject, status: `${c.sha} · ${c.when}`, recommended: i === 0 })), commits[0].sha);
|
|
20
|
+
}
|
|
21
|
+
/** The latest commits, newest first, for picking a starting point. */
|
|
22
|
+
export function recentCommits(root, git, count = 15) {
|
|
23
|
+
try {
|
|
24
|
+
return git(['log', '--no-merges', `-${count}`, '--format=%h%x1f%s%x1f%ar'], root)
|
|
25
|
+
.trim()
|
|
26
|
+
.split('\n')
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map((line) => {
|
|
29
|
+
const [sha, subject, when] = line.split('\x1f');
|
|
30
|
+
return { sha, subject: subject.length > 60 ? `${subject.slice(0, 57)}...` : subject, when };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Where the edits go, after the preview. */
|
|
38
|
+
export async function askDestination(pages, canOpenPr, deps) {
|
|
39
|
+
return deps.pick(pages ? `Write the edits for ${pages === 1 ? 'this page' : 'these pages'}?` : 'No page names these changes. Ask the AI anyway?', [
|
|
40
|
+
{ value: 'files', label: 'Yes, into the files here', description: 'You read them, then commit them like any change.', recommended: true },
|
|
41
|
+
...(canOpenPr ? [{ value: 'pr', label: 'Yes, as a pull request', description: 'Pushed to the docugate/sync branch for someone to review.' }] : []),
|
|
42
|
+
{ value: 'stop', label: 'Not now' },
|
|
43
|
+
], 'files');
|
|
44
|
+
}
|
|
45
|
+
/** Asks for an AI key once and keeps it on this computer. Null when none was given. */
|
|
46
|
+
export async function askForKey(deps) {
|
|
47
|
+
console.log(`\n ${dim('Writing the edits uses your own AI model. The key stays on this computer and never goes through DocuGate.')}`);
|
|
48
|
+
const key = (await deps.line(' Paste an Anthropic or OpenAI key (Enter to skip): ')).trim();
|
|
49
|
+
if (!key)
|
|
50
|
+
return null;
|
|
51
|
+
const saved = { provider: providerOfKey(key), key };
|
|
52
|
+
(deps.save ?? saveKey)(saved);
|
|
53
|
+
console.log(` ${dim(`Saved. Later runs use it without asking.`)}`);
|
|
54
|
+
return saved;
|
|
55
|
+
}
|
package/dist/sync/command.js
CHANGED
|
@@ -10,8 +10,11 @@ import { defaultGit, SyncError } from './changes.js';
|
|
|
10
10
|
import { installHook, removeHook } from './hook.js';
|
|
11
11
|
import { runSync } from './run.js';
|
|
12
12
|
import { repoFromRemote } from './pr.js';
|
|
13
|
-
import { printFindings, projectToken, readSettings, reportRun, runAudit } from '../project.js';
|
|
13
|
+
import { askLine, pickOne, printFindings, projectToken, readSettings, reportRun, runAudit } from '../project.js';
|
|
14
14
|
import { writeWorkflow, WORKFLOW_FILE } from './workflow.js';
|
|
15
|
+
import { KEY_HELP, resolveProvider, withSavedKey } from './ai.js';
|
|
16
|
+
import { askDestination, askForKey, askSince } from './ask.js';
|
|
17
|
+
import { readSyncState } from './state.js';
|
|
15
18
|
const out = (line = '') => console.log(line);
|
|
16
19
|
/** `docugate sync ...`. Returns the exit code. */
|
|
17
20
|
export async function syncCommand(root, args, deps = {}) {
|
|
@@ -24,6 +27,9 @@ export async function syncCommand(root, args, deps = {}) {
|
|
|
24
27
|
if (args.sub)
|
|
25
28
|
throw new SyncError(`Unknown sync command "${args.sub}". Try: sync, sync init, sync hook install, sync hook remove.`);
|
|
26
29
|
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI && !args.bools.has('no-input');
|
|
30
|
+
deps = { ...deps, env: withSavedKey(deps.env ?? process.env) };
|
|
31
|
+
if (args.bools.has('ask') && interactive)
|
|
32
|
+
return askedSync(root, args, deps);
|
|
27
33
|
const options = {
|
|
28
34
|
since: args.since,
|
|
29
35
|
dryRun: args.bools.has('dry-run'),
|
|
@@ -78,6 +84,44 @@ export async function syncCommand(root, args, deps = {}) {
|
|
|
78
84
|
throw error;
|
|
79
85
|
}
|
|
80
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The menu's "Update the docs": which changes, a free preview of the pages they
|
|
89
|
+
* touch, then where the edits go. The answers become an ordinary sync.
|
|
90
|
+
*/
|
|
91
|
+
async function askedSync(root, args, deps) {
|
|
92
|
+
const askDeps = { pick: (title, choices, fallback) => pickOne(title, choices, fallback, askLine), line: askLine };
|
|
93
|
+
const since = await askSince(root, readSyncState(root).lastRunAt, askDeps);
|
|
94
|
+
if (!since)
|
|
95
|
+
return 0;
|
|
96
|
+
const preview = await runSync(root, { since, dryRun: true }, deps);
|
|
97
|
+
report(preview, false, false);
|
|
98
|
+
if (preview.skipped)
|
|
99
|
+
return 0;
|
|
100
|
+
let env = deps.env ?? process.env;
|
|
101
|
+
if (!resolveProvider(env, loadConfig(root).config.sync)) {
|
|
102
|
+
const saved = await askForKey(askDeps);
|
|
103
|
+
if (!saved) {
|
|
104
|
+
out(` ${dim(KEY_HELP)}`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
env = withSavedKey(env, saved);
|
|
108
|
+
}
|
|
109
|
+
let remote = '';
|
|
110
|
+
try {
|
|
111
|
+
remote = repoFromRemote(defaultGit(['remote', 'get-url', 'origin'], root)) ?? '';
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// No remote: the edits can only go into the files.
|
|
115
|
+
}
|
|
116
|
+
const destination = await askDestination(preview.impact?.pages.length ?? 0, Boolean(remote), askDeps);
|
|
117
|
+
if (!destination || destination === 'stop')
|
|
118
|
+
return 0;
|
|
119
|
+
const bools = new Set([...args.bools].filter((b) => b !== 'ask'));
|
|
120
|
+
bools.add('yes');
|
|
121
|
+
if (destination === 'pr')
|
|
122
|
+
bools.add('pr');
|
|
123
|
+
return syncCommand(root, { ...args, since, bools }, { ...deps, env });
|
|
124
|
+
}
|
|
81
125
|
function headCommit(root) {
|
|
82
126
|
try {
|
|
83
127
|
return defaultGit(['rev-parse', '--short', 'HEAD'], root).trim();
|
|
@@ -100,7 +144,7 @@ async function ask(question) {
|
|
|
100
144
|
rl.close();
|
|
101
145
|
}
|
|
102
146
|
}
|
|
103
|
-
function report(r, quiet) {
|
|
147
|
+
function report(r, quiet, next = true) {
|
|
104
148
|
const affected = r.impact?.pages ?? [];
|
|
105
149
|
if (quiet) {
|
|
106
150
|
if (affected.length) {
|
|
@@ -138,7 +182,8 @@ function report(r, quiet) {
|
|
|
138
182
|
}
|
|
139
183
|
staleTour(r);
|
|
140
184
|
out();
|
|
141
|
-
|
|
185
|
+
if (next)
|
|
186
|
+
out(` ${dim('Next:')} docugate sync`);
|
|
142
187
|
out();
|
|
143
188
|
return 0;
|
|
144
189
|
}
|
|
@@ -228,12 +273,17 @@ function syncInit(root, args) {
|
|
|
228
273
|
/* no remote yet */
|
|
229
274
|
}
|
|
230
275
|
const key = loaded.config.sync?.provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
|
276
|
+
const managed = Boolean(projectToken(root));
|
|
231
277
|
out();
|
|
232
|
-
out(` ${bold('Two things to do on GitHub, once
|
|
278
|
+
out(` ${bold(`${managed ? 'Three' : 'Two'} things to do on GitHub, once:`)}`);
|
|
233
279
|
out(` 1. Add your AI key as a repository secret named ${gold(key)}`);
|
|
234
280
|
out(` ${dim(repo ? `https://github.com/${repo}/settings/secrets/actions` : 'Settings > Secrets and variables > Actions')}`);
|
|
235
281
|
out(` 2. Allow Actions to open pull requests: Settings > Actions > General > Workflow permissions`);
|
|
236
282
|
out(` ${dim('and tick "Allow GitHub Actions to create and approve pull requests"')}`);
|
|
283
|
+
if (managed) {
|
|
284
|
+
out(` 3. Add a secret named ${gold('DOCUGATE_PROJECT_TOKEN')}, so the dashboard sees each run`);
|
|
285
|
+
out(` ${dim("Make one on the codebase's page in the dashboard: Settings, Access, Replace the token")}`);
|
|
286
|
+
}
|
|
237
287
|
out();
|
|
238
288
|
out(` Then commit the workflow. ${dim('Next:')} docugate sync hook install ${dim('(optional: a heads-up after each commit)')}`);
|
|
239
289
|
out();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docugate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Command-line tools for DocuGate: set up, check and prepare the documentation DocuGate reads straight from your repository. Also checks DocuGate API keys from Express, Hono and Fastify.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|