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 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 from the code',
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: 'Reads what changed in the code, asks your AI model for the docs edits, and writes them. Add --pr from the command line to open a pull request instead.',
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: 'Keep the docs current automatically',
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(' ')] : []), 'status', 'openapi', 'help'),
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('developers', 'init', 'sync', 'check'),
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 role === 'frontend' ? 'frontend' : 'backend';
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
- async function choose(question, options, fallback, ask) {
210
- const answer = (await ask(` ${question} ${dim(`(${options.join(', ')})`)} [${fallback}] `)).trim().toLowerCase();
211
- return options.includes(answer) ? answer : fallback;
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 rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
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 (!rl || !/^(|y|yes)$/i.test((await ask(` Sign in to DocuGate first? ${dim('(Y/n)')} `)).trim())) {
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 (rl && choices.length) {
265
- console.log(` ${bold('Which project is this codebase part of?')}`);
266
- choices.forEach((p, i) => {
267
- const parts = p.codebases.map((c) => `${c.type}: ${c.repo}${c.path ? `/${c.path}` : ''}`);
268
- console.log(` ${i + 1}. ${p.name}${p.org ? dim(` (${p.org})`) : ''} ${dim(parts.length ? `· ${parts.join(', ')}` : '· no codebase yet')}`);
269
- });
270
- console.log(` ${dim('n. A new project, from this codebase’s docs')}`);
271
- const answer = (await ask(` ${dim('(number, n, or empty for none yet)')} `)).trim().toLowerCase();
272
- if (answer === 'n') {
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[Number(answer) - 1];
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 type = args.type ?? (await choose('What is this codebase?', PROJECT_TYPES, detectType(root), ask));
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 ?? (await choose('How often should docs be checked?', FREQUENCIES, 'weekly', ask));
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 = rl ? (await ask(` Some of its pages share an address with pages already there. Put them under a section? [${fallback}] `)).trim() || fallback : fallback;
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(`To check API keys, install the package ${inside}:`)} npm i docugate`);
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
- console.log(` For scheduled syncs, add this token as a GitHub secret. It is shown once:`);
357
- console.log(` gh secret set DOCUGATE_PROJECT_TOKEN --body ${token}`);
358
- console.log(` ${dim('Then run docugate sync init --force so the workflow follows the dashboard.')}`);
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
@@ -10,6 +10,8 @@ export type Status = {
10
10
  project: {
11
11
  git: boolean;
12
12
  setUp: boolean;
13
+ /** Linked to a DocuGate project with `docugate connect`, so the dashboard sees it. */
14
+ connected: boolean;
13
15
  docsDir: string;
14
16
  pages: number;
15
17
  role?: string;
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: env.ANTHROPIC_API_KEY ? 'anthropic' : env.OPENAI_API_KEY ? 'openai' : null,
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 in this shell',
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>;
@@ -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
+ }
@@ -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
- out(` ${dim('Next:')} docugate sync`);
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.2",
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": {