docugate 0.4.1 → 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 +9 -7
- package/dist/api.js +13 -3
- package/dist/cli.js +1 -1
- package/dist/commands.js +2 -1
- package/dist/developers.js +1 -1
- package/dist/init.js +1 -1
- package/dist/keys/core.js +1 -1
- package/dist/menu.js +27 -8
- package/dist/project.d.ts +8 -0
- package/dist/project.js +90 -30
- 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/dist/sync/run.js +1 -1
- package/dist/tour-server.js +8 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -147,19 +147,21 @@ On Pro it carries on:
|
|
|
147
147
|
7. Asks where the API console sends requests from: the reader's browser, or through DocuGate with no CORS setup.
|
|
148
148
|
8. Switches on API keys, with a scope per OpenAPI tag to start from, makes the verify secret, offers to put it in `.env` when git ignores that file, and prints the lines your Express, Hono or Fastify backend needs.
|
|
149
149
|
|
|
150
|
-
Everything it sets stays editable in the space's settings on [trydocugate.site](https://
|
|
150
|
+
Everything it sets stays editable in the space's settings on [trydocugate.site](https://trydocugate.site/dashboard).
|
|
151
151
|
|
|
152
152
|
## Docs that keep up with your code
|
|
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. |
|
|
@@ -412,11 +414,11 @@ cd server && npx docugate connect # pick the project, or start a new one
|
|
|
412
414
|
cd ../public && npx docugate connect # pick the same project
|
|
413
415
|
```
|
|
414
416
|
|
|
415
|
-
The first codebase's docs sit at the top of the project's space; each one after it gets a section named after what it is (`frontend/`, `backend/`), so two pages with the same name cannot clash. A docs folder the space already reads is not added twice. Change the sections, or add repositories by hand, in a space's settings on [trydocugate.site](https://
|
|
417
|
+
The first codebase's docs sit at the top of the project's space; each one after it gets a section named after what it is (`frontend/`, `backend/`), so two pages with the same name cannot clash. A docs folder the space already reads is not added twice. Change the sections, or add repositories by hand, in a space's settings on [trydocugate.site](https://trydocugate.site/dashboard). Feeding one project from several codebases needs Pro or an organization plan.
|
|
416
418
|
|
|
417
419
|
Run `init` and `check` in each repository that contributes docs, and answer the role question in each: the tour lives in the frontend, and its `source` lines can point into the backend. Every one gets its own `docugate.json`, naming its own docs folder. `check` reads the repository it is run in, which is why its 300-page limit is per repository rather than per space.
|
|
418
420
|
|
|
419
|
-
See [Merging repositories](https://
|
|
421
|
+
See [Merging repositories](https://trydocugate.site/docs/merging-repositories).
|
|
420
422
|
|
|
421
423
|
---
|
|
422
424
|
|
|
@@ -471,7 +473,7 @@ const verdict = await verifyKey(key, { secret: process.env.DOCUGATE_VERIFY_SECRE
|
|
|
471
473
|
if (!verdict.valid) return deny(verdict.code) // REVOKED, EXPIRED, USAGE_EXCEEDED, ...
|
|
472
474
|
```
|
|
473
475
|
|
|
474
|
-
`verifyKey` answers for every key, valid or not, and throws a `KeyServiceError` only when it could not get an answer. By default every request asks DocuGate, which is what makes a revoked key stop at once; `cacheSeconds` trades that for speed, and `failOpen` decides what happens when DocuGate cannot be reached (by default, the request is refused). The options, the exact request, and what each result means are in [API keys](https://
|
|
476
|
+
`verifyKey` answers for every key, valid or not, and throws a `KeyServiceError` only when it could not get an answer. By default every request asks DocuGate, which is what makes a revoked key stop at once; `cacheSeconds` trades that for speed, and `failOpen` decides what happens when DocuGate cannot be reached (by default, the request is refused). The options, the exact request, and what each result means are in [API keys](https://trydocugate.site/docs/api-keys).
|
|
475
477
|
|
|
476
478
|
---
|
|
477
479
|
|
|
@@ -488,7 +490,7 @@ One per repository, at its root.
|
|
|
488
490
|
| `sync` | Settings for `docugate sync`; see [Docs that keep up with your code](#docs-that-keep-up-with-your-code). The DocuGate site ignores it. |
|
|
489
491
|
| `sidebar` | File and folder names in the order the sidebar should show them. Orders this repository's own pages, not the whole space. |
|
|
490
492
|
| `api.generate` | Defaults for `docugate openapi`. |
|
|
491
|
-
| `api.spec` | Where the OpenAPI file is, for a repository that does not keep it at `openapi.json` in the root or the docs folder. DocuGate draws an [API reference](https://
|
|
493
|
+
| `api.spec` | Where the OpenAPI file is, for a repository that does not keep it at `openapi.json` in the root or the docs folder. DocuGate draws an [API reference](https://trydocugate.site/docs/api-reference) from it. |
|
|
492
494
|
|
|
493
495
|
---
|
|
494
496
|
|
package/dist/api.js
CHANGED
|
@@ -22,7 +22,7 @@ export function detectAgent(env = process.env) {
|
|
|
22
22
|
return 'Terminal';
|
|
23
23
|
}
|
|
24
24
|
/** Module-level default, captured at import time (used by CLI commands). */
|
|
25
|
-
export const BASE_URL = process.env.DOCUGATE_URL ?? 'https://
|
|
25
|
+
export const BASE_URL = process.env.DOCUGATE_URL ?? 'https://trydocugate.site';
|
|
26
26
|
const ERROR_MESSAGES = {
|
|
27
27
|
pro_required_sources: 'Connecting more than one repository to a space needs DocuGate Pro',
|
|
28
28
|
space_limit: 'The free plan has one space. Connect this repository to it, or upgrade to Pro',
|
|
@@ -60,8 +60,18 @@ export async function apiFetch(path, init = {}) {
|
|
|
60
60
|
headers['x-docugate-version'] = CLI_VERSION;
|
|
61
61
|
}
|
|
62
62
|
// Re-read the env at call time so tests that set DOCUGATE_URL after import work.
|
|
63
|
-
const base = process.env.DOCUGATE_URL ?? 'https://
|
|
64
|
-
|
|
63
|
+
const base = process.env.DOCUGATE_URL ?? 'https://trydocugate.site';
|
|
64
|
+
let res = await fetch(`${base}${path}`, { ...init, headers, redirect: 'manual' });
|
|
65
|
+
// fetch drops the Authorization header when a redirect changes host, so a
|
|
66
|
+
// move between www.trydocugate.site and trydocugate.site read as signed out.
|
|
67
|
+
// Followed here instead, once, and only between two names of the same site.
|
|
68
|
+
const moved = res.status >= 301 && res.status <= 308 ? res.headers.get('location') : null;
|
|
69
|
+
if (moved) {
|
|
70
|
+
const to = new URL(moved, base);
|
|
71
|
+
const bare = (host) => host.replace(/^www\./, '');
|
|
72
|
+
if (to.protocol === 'https:' && bare(to.host) === bare(new URL(base).host))
|
|
73
|
+
res = await fetch(to, { ...init, headers, redirect: 'manual' });
|
|
74
|
+
}
|
|
65
75
|
// Renew token if the server sent a fresh one.
|
|
66
76
|
const renewed = res.headers.get('x-docugate-token');
|
|
67
77
|
if (renewed && !envToken)
|
package/dist/cli.js
CHANGED
|
@@ -23,7 +23,7 @@ import { startTourServer } from './tour-server.js';
|
|
|
23
23
|
import { login, clearCredentials, loadCredentials } from './auth.js';
|
|
24
24
|
import { BASE_URL, getSession } from './api.js';
|
|
25
25
|
import { dim, gold, green, hint, isMintty, plural, red, suggest, yellow } from './ui.js';
|
|
26
|
-
const SITE = 'https://
|
|
26
|
+
const SITE = 'https://trydocugate.site';
|
|
27
27
|
/** True while the launch menu is running: its commands do not print their own "Next". */
|
|
28
28
|
let inMenu = false;
|
|
29
29
|
function fail(message, tip) {
|
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/developers.js
CHANGED
|
@@ -13,7 +13,7 @@ import { bold, dim, gold, green, plural, yellow } from './ui.js';
|
|
|
13
13
|
// console sends requests, switches on API keys and hands over the verify
|
|
14
14
|
// secret with the lines the backend needs. Every step says what it is about to
|
|
15
15
|
// do, and nothing that already exists is overwritten.
|
|
16
|
-
const SITE = 'https://
|
|
16
|
+
const SITE = 'https://trydocugate.site';
|
|
17
17
|
/** Folders never worth walking for a spec. */
|
|
18
18
|
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', 'vendor', '.venv', 'venv', 'target']);
|
|
19
19
|
/** File names that are, by convention, an API description. */
|
package/dist/init.js
CHANGED
|
@@ -480,7 +480,7 @@ export async function initFlow(root, options = {}) {
|
|
|
480
480
|
saveRole(root, role);
|
|
481
481
|
}
|
|
482
482
|
// ── Step 3: sign in and connect this repository ───────────────────────────
|
|
483
|
-
const BASE_URL = options.baseUrl ?? process.env.DOCUGATE_URL ?? 'https://
|
|
483
|
+
const BASE_URL = options.baseUrl ?? process.env.DOCUGATE_URL ?? 'https://trydocugate.site';
|
|
484
484
|
let connectError;
|
|
485
485
|
// In CI (no TTY) skip sign-in entirely (hint already printed in the early-return branch above).
|
|
486
486
|
if (!io.isTTY) {
|
package/dist/keys/core.js
CHANGED
|
@@ -9,7 +9,7 @@ import { looksLikeKey } from './format.js';
|
|
|
9
9
|
* request. This file is that question and what to do with the answer. The
|
|
10
10
|
* adapters for Express, Hono and Fastify are a few lines on top of it.
|
|
11
11
|
*/
|
|
12
|
-
const DEFAULT_BASE_URL = 'https://
|
|
12
|
+
const DEFAULT_BASE_URL = 'https://trydocugate.site';
|
|
13
13
|
/** DocuGate could not be asked, or refused to answer. Never means a key is bad. */
|
|
14
14
|
export class KeyServiceError extends Error {
|
|
15
15
|
constructor(message, options) {
|
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
|
|
@@ -18,7 +19,7 @@ import { bold, dim, green, plural, red, yellow } from './ui.js';
|
|
|
18
19
|
export const PROJECT_TYPES = ['frontend', 'backend', 'serverless', 'monorepo'];
|
|
19
20
|
export const FREQUENCIES = ['push', 'daily', 'weekly', 'off'];
|
|
20
21
|
const TOKENS_FILE = () => join(process.env.DOCUGATE_HOME ?? join(homedir(), '.docugate'), 'projects.json');
|
|
21
|
-
const base = () => process.env.DOCUGATE_URL ?? 'https://
|
|
22
|
+
const base = () => process.env.DOCUGATE_URL ?? 'https://trydocugate.site';
|
|
22
23
|
function readTokens() {
|
|
23
24
|
try {
|
|
24
25
|
return JSON.parse(readFileSync(TOKENS_FILE(), 'utf8'));
|
|
@@ -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/dist/sync/run.js
CHANGED
|
@@ -90,7 +90,7 @@ export async function runSync(root, options = {}, deps = {}) {
|
|
|
90
90
|
writeSyncState(root, { lastSha: changes.to, lastRunAt: (deps.now ?? new Date()).toISOString() });
|
|
91
91
|
if (options.pr) {
|
|
92
92
|
const prFn = deps.prFn ?? defaultPr({ git, env });
|
|
93
|
-
const siteUrl = env.DOCUGATE_URL ?? 'https://
|
|
93
|
+
const siteUrl = env.DOCUGATE_URL ?? 'https://trydocugate.site';
|
|
94
94
|
result.pr = await prFn({
|
|
95
95
|
root,
|
|
96
96
|
title: `docs: sync with code changes (${changes.commits.length} commit${changes.commits.length === 1 ? '' : 's'})`,
|
package/dist/tour-server.js
CHANGED
|
@@ -104,7 +104,7 @@ export function publishedDocsUrl(root) {
|
|
|
104
104
|
const space = loadConfig(root).config.space;
|
|
105
105
|
if (!space || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(space))
|
|
106
106
|
return undefined;
|
|
107
|
-
return `${process.env.DOCUGATE_URL ?? 'https://
|
|
107
|
+
return `${process.env.DOCUGATE_URL ?? 'https://trydocugate.site'}/${space}`;
|
|
108
108
|
}
|
|
109
109
|
function handleGetEvents(req, res, root) {
|
|
110
110
|
const tourDir = join(root, '.docugate', 'tour');
|
|
@@ -379,13 +379,13 @@ export function startTourServer(root, port) {
|
|
|
379
379
|
// Somebody opened the tour server itself. Say what it is and where to go.
|
|
380
380
|
const screens = loadTours(root).length;
|
|
381
381
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
382
|
-
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>DocuGate tour server</title>
|
|
383
|
-
<style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0F1014;color:#F5F0E3;font:15px/1.6 system-ui,sans-serif}
|
|
384
|
-
main{max-width:440px;padding:32px;border:1px solid #25273a;border-radius:16px;background:#15173a}
|
|
385
|
-
h1{font-size:20px;margin:0 0 8px}p{margin:0 0 12px;color:#aaa597}b{color:#F4C43F;font-weight:600}
|
|
386
|
-
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#45d483;margin-right:8px}</style></head>
|
|
387
|
-
<body><main><h1><span class="dot"></span>The DocuGate tour server is running</h1>
|
|
388
|
-
<p>This is not a page to open. It feeds the Tour button in your app (${screens} screen${screens === 1 ? '' : 's'} in <b>.docugate/tour/</b>).</p>
|
|
382
|
+
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>DocuGate tour server</title>
|
|
383
|
+
<style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0F1014;color:#F5F0E3;font:15px/1.6 system-ui,sans-serif}
|
|
384
|
+
main{max-width:440px;padding:32px;border:1px solid #25273a;border-radius:16px;background:#15173a}
|
|
385
|
+
h1{font-size:20px;margin:0 0 8px}p{margin:0 0 12px;color:#aaa597}b{color:#F4C43F;font-weight:600}
|
|
386
|
+
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#45d483;margin-right:8px}</style></head>
|
|
387
|
+
<body><main><h1><span class="dot"></span>The DocuGate tour server is running</h1>
|
|
388
|
+
<p>This is not a page to open. It feeds the Tour button in your app (${screens} screen${screens === 1 ? '' : 's'} in <b>.docugate/tour/</b>).</p>
|
|
389
389
|
<p>Open <b>your app</b> instead, for example <b>http://localhost:5173</b> or <b>http://localhost:3000</b>. The Tour button is in the bottom-right corner.</p></main></body></html>`);
|
|
390
390
|
}
|
|
391
391
|
else {
|
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": {
|