docugate 0.4.0 → 0.4.1

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
@@ -363,7 +363,7 @@ Pin the version in CI, as above. Without a pin, the rules being enforced can cha
363
363
  | `docugate openapi` | `--from`, `--out`, `--include`, `--exclude`, `--redact`, `--check` | Prepare an OpenAPI spec for the API reference. |
364
364
  | `docugate login` | | Sign in to DocuGate with GitHub. |
365
365
  | `docugate logout` | | Sign out and delete saved credentials. |
366
- | `docugate connect` | `--space`, `--frequency`, `--type`, `--backend-dir`, `--no-audit`, `--yes` | Connect this project to your account, to run it from the dashboard. |
366
+ | `docugate connect` | `--project`, `--section`, `--frequency`, `--type`, `--backend-dir`, `--no-audit`, `--yes` | Connect this codebase to a project, and publish its docs with the project's. |
367
367
  | `docugate audit` | `--fail-on`, `--json` | Check this project’s packages for known vulnerabilities. |
368
368
  | `docugate whoami` | | Print the currently signed-in user. |
369
369
  | `docugate tour init` | `--max-cost <n>`, `--team-id <id>` | Write the first tour with IBM Bob (uses Bob Shell). |
@@ -403,9 +403,16 @@ Or try it without installing anything: https://docugate-demo.vercel.app/invoices
403
403
 
404
404
  ---
405
405
 
406
- ## Several repositories in one space
406
+ ## One project, several codebases
407
407
 
408
- A space can merge docs from a frontend, a backend, and a mobile app into one set of pages. `docugate init` connects the repository it runs in to one of your spaces once you are signed in; you can also add repositories from a space's settings on [trydocugate.site](https://www.trydocugate.site/dashboard).
408
+ A project is one set of docs fed by its codebases: a frontend and a backend, in two repositories or as two folders of one. Run `docugate connect` in each of them:
409
+
410
+ ```bash
411
+ cd server && npx docugate connect # pick the project, or start a new one
412
+ cd ../public && npx docugate connect # pick the same project
413
+ ```
414
+
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://www.trydocugate.site/dashboard). Feeding one project from several codebases needs Pro or an organization plan.
409
416
 
410
417
  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.
411
418
 
package/dist/api.js CHANGED
@@ -32,6 +32,13 @@ const ERROR_MESSAGES = {
32
32
  repo_exists: 'You already have a repository with this name on GitHub',
33
33
  app_permission: "DocuGate isn't allowed to create repositories on your account yet. Accept the DocuGate app's updated permissions at https://github.com/settings/installations, then try again",
34
34
  github_required: 'Sign in with GitHub to create a space',
35
+ source_collision: 'Some pages would share an address with pages the space already has. Put them under a section',
36
+ duplicate_source: 'The space already reads that folder of that repository',
37
+ duplicate_mount: 'Another repository already uses that section. Choose another name',
38
+ bad_mount: 'A section is a short name: letters, numbers and dashes, up to three levels',
39
+ bad_path: 'DocuGate could not read which folder of the repository this is',
40
+ bad_space: 'That project is not one you can connect to',
41
+ forbidden: 'You can only change a space you own, or one whose repository you can push to',
35
42
  };
36
43
  function friendlyError(code) {
37
44
  return ERROR_MESSAGES[code] ?? `DocuGate said: ${code}`;
package/dist/cli.js CHANGED
@@ -213,7 +213,9 @@ async function run(argv) {
213
213
  }
214
214
  case 'connect': {
215
215
  process.exitCode = await connectCommand(root, {
216
- space: one('space'),
216
+ // --space is what it was called before a project could have several codebases.
217
+ project: one('project') ?? one('space'),
218
+ section: one('section'),
217
219
  frequency: one('frequency'),
218
220
  type: one('type'),
219
221
  backendDir: one('backend-dir'),
package/dist/commands.js CHANGED
@@ -166,18 +166,20 @@ export const COMMANDS = [
166
166
  {
167
167
  name: 'connect',
168
168
  group: 'Account',
169
- summary: 'Connect this project to your account, to run it from the dashboard',
170
- description: 'Links this repository to your DocuGate account. Then the dashboard decides which documentation it updates, how often, and what kind of project it is. ' +
169
+ summary: 'Connect this codebase to a project, and publish its docs with the project’s',
170
+ description: 'Run it in each codebase of a project: the backend, then the frontend, whether they are two repositories or two folders of one. ' +
171
+ 'It asks which project the codebase belongs to, or starts a new one, and adds its docs folder to the project’s space, under a section of its own when the project already has docs. ' +
171
172
  'It shares the repository name, the tool running the CLI, the outcome of each sync, and the names and versions of public packages in your lockfiles. Never your code.',
172
173
  flags: [
173
- { name: 'space', value: 'slug', description: 'The space this project updates' },
174
+ { name: 'project', value: 'name', description: 'The project to join, by slug, owner/slug or name (--space still works)' },
175
+ { name: 'section', value: 'name', description: 'The section its docs go under (default: frontend, backend… after the first codebase)' },
174
176
  { name: 'frequency', value: 'when', description: 'How often docs are checked: push, daily, weekly or off' },
175
177
  { name: 'type', value: 'kind', description: 'frontend, backend, serverless or monorepo (default: detected)' },
176
178
  { name: 'backend-dir', value: 'folder', description: 'In a monorepo, the folder that holds the backend' },
177
179
  { name: 'no-audit', description: 'Do not send package names to check for vulnerabilities' },
178
180
  YES,
179
181
  ],
180
- examples: ['docugate connect', 'docugate connect --space handbook --frequency daily --type monorepo --backend-dir apps/api --yes'],
182
+ examples: ['docugate connect', 'docugate connect --project payments --type backend --yes', 'docugate connect --project payments --type frontend --section web --yes'],
181
183
  account: 'required',
182
184
  },
183
185
  {
package/dist/project.d.ts CHANGED
@@ -25,7 +25,7 @@ export type Finding = {
25
25
  };
26
26
  /** `owner/name` from the git remote, or the folder's name without one. */
27
27
  export declare function detectRepo(root: string): string;
28
- /** The token for this repository: CI's secret first, then what `connect` saved. */
28
+ /** The token for this codebase: CI's secret first, then what `connect` saved for this folder, then for its repository. */
29
29
  export declare function projectToken(root: string, env?: NodeJS.ProcessEnv): string | null;
30
30
  /** A suggestion only. The person confirms it, and the dashboard can change it. */
31
31
  export declare function detectType(root: string): ProjectType;
@@ -61,12 +61,59 @@ export type AuditArgs = {
61
61
  /** `docugate audit`. Returns the exit code. */
62
62
  export declare function auditCommand(root: string, args: AuditArgs, env?: NodeJS.ProcessEnv): Promise<number>;
63
63
  export type ConnectArgs = {
64
- space?: string;
64
+ /** The project to join: its slug, `owner/slug`, or name. */
65
+ project?: string;
65
66
  frequency?: string;
66
67
  type?: string;
67
68
  backendDir?: string;
69
+ /** The section this codebase's docs go under in the project's space. Empty is the top level. */
70
+ section?: string;
68
71
  noAudit: boolean;
69
72
  yes: boolean;
70
73
  };
71
- /** `docugate connect`. Returns the exit code. */
74
+ /** A project `connect` can join: a space, with the codebases and docs folders already feeding it. */
75
+ export type ProjectChoice = {
76
+ id: string;
77
+ name: string;
78
+ owner: string;
79
+ slug: string;
80
+ org: string | null;
81
+ sources: {
82
+ repo: string;
83
+ docsDir: string;
84
+ mount: string;
85
+ }[];
86
+ codebases: {
87
+ repo: string;
88
+ path: string;
89
+ type: string;
90
+ }[];
91
+ };
92
+ /**
93
+ * Which codebase this folder is: its repository, and where in it. A frontend and
94
+ * a backend side by side in one repository are two codebases, told apart by
95
+ * their folder. `path` is `''` at the top of the repository.
96
+ */
97
+ export declare function codebaseOf(root: string, git?: (args: string[], cwd: string) => string): {
98
+ repo: string;
99
+ path: string;
100
+ };
101
+ /** Where a codebase's project token is kept: the repository, and the folder when it is not the whole of it. */
102
+ export declare const tokenKey: (repo: string, path: string) => string;
103
+ /**
104
+ * The section a codebase's docs go under. The first codebase of a project is the
105
+ * top level; each one after it gets a section named after what it is, so two
106
+ * pages called "Authentication" cannot clash.
107
+ */
108
+ export declare function sectionFor(type: ProjectType, sources: {
109
+ mount: string;
110
+ }[]): string;
111
+ /** The project someone named: by slug, `owner/slug` or name, in any case. */
112
+ export declare function pickProject(choices: ProjectChoice[], wanted: string): ProjectChoice | undefined;
113
+ /**
114
+ * `docugate connect`, run in each codebase of a project: the backend, then the
115
+ * frontend, whether they are two repositories or two folders of one. It joins
116
+ * the codebase to the project, and adds its docs to the project's space so they
117
+ * are published with the rest. Returns the exit code.
118
+ */
72
119
  export declare function connectCommand(root: string, args: ConnectArgs): Promise<number>;
package/dist/project.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { basename, join } from 'node:path';
3
+ import { basename, join, relative, resolve, sep } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { apiFetch } from './api.js';
6
+ import { login } from './auth.js';
6
7
  import { packageVersion } from './banner.js';
7
8
  import { loadConfig } from './config.js';
8
9
  import { guessRole } from './init.js';
@@ -38,9 +39,13 @@ export function detectRepo(root) {
38
39
  }
39
40
  return basename(root);
40
41
  }
41
- /** The token for this repository: CI's secret first, then what `connect` saved. */
42
+ /** The token for this codebase: CI's secret first, then what `connect` saved for this folder, then for its repository. */
42
43
  export function projectToken(root, env = process.env) {
43
- return env.DOCUGATE_PROJECT_TOKEN || readTokens()[detectRepo(root)] || null;
44
+ if (env.DOCUGATE_PROJECT_TOKEN)
45
+ return env.DOCUGATE_PROJECT_TOKEN;
46
+ const { repo, path } = codebaseOf(root);
47
+ const tokens = readTokens();
48
+ return tokens[tokenKey(repo, path)] || tokens[repo] || null;
44
49
  }
45
50
  /** A suggestion only. The person confirms it, and the dashboard can change it. */
46
51
  export function detectType(root) {
@@ -162,11 +167,56 @@ export async function auditCommand(root, args, env = process.env) {
162
167
  }
163
168
  return 0;
164
169
  }
170
+ /**
171
+ * Which codebase this folder is: its repository, and where in it. A frontend and
172
+ * a backend side by side in one repository are two codebases, told apart by
173
+ * their folder. `path` is `''` at the top of the repository.
174
+ */
175
+ export function codebaseOf(root, git = defaultGit) {
176
+ const repo = detectRepo(root);
177
+ try {
178
+ const top = git(['rev-parse', '--show-toplevel'], root).trim();
179
+ const path = relative(resolve(top), resolve(root)).split(sep).join('/');
180
+ return { repo, path: path.startsWith('..') ? '' : path };
181
+ }
182
+ catch {
183
+ return { repo, path: '' };
184
+ }
185
+ }
186
+ /** Where a codebase's project token is kept: the repository, and the folder when it is not the whole of it. */
187
+ export const tokenKey = (repo, path) => (path ? `${repo}#${path}` : repo);
188
+ /**
189
+ * The section a codebase's docs go under. The first codebase of a project is the
190
+ * top level; each one after it gets a section named after what it is, so two
191
+ * pages called "Authentication" cannot clash.
192
+ */
193
+ export function sectionFor(type, sources) {
194
+ if (!sources.length)
195
+ return '';
196
+ const wanted = type === 'monorepo' ? 'app' : type;
197
+ const taken = new Set(sources.map((s) => s.mount));
198
+ if (!taken.has(wanted))
199
+ return wanted;
200
+ for (let n = 2;; n++)
201
+ if (!taken.has(`${wanted}-${n}`))
202
+ return `${wanted}-${n}`;
203
+ }
204
+ /** The project someone named: by slug, `owner/slug` or name, in any case. */
205
+ export function pickProject(choices, wanted) {
206
+ const w = wanted.trim().toLowerCase();
207
+ 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
+ }
165
209
  async function choose(question, options, fallback, ask) {
166
210
  const answer = (await ask(` ${question} ${dim(`(${options.join(', ')})`)} [${fallback}] `)).trim().toLowerCase();
167
211
  return options.includes(answer) ? answer : fallback;
168
212
  }
169
- /** `docugate connect`. Returns the exit code. */
213
+ const codeOf = (error) => error.code;
214
+ /**
215
+ * `docugate connect`, run in each codebase of a project: the backend, then the
216
+ * frontend, whether they are two repositories or two folders of one. It joins
217
+ * the codebase to the project, and adds its docs to the project's space so they
218
+ * are published with the rest. Returns the exit code.
219
+ */
170
220
  export async function connectCommand(root, args) {
171
221
  if (args.type && !PROJECT_TYPES.includes(args.type)) {
172
222
  console.error(`${red('error')} --type must be one of ${PROJECT_TYPES.join(', ')}.`);
@@ -176,36 +226,68 @@ export async function connectCommand(root, args) {
176
226
  console.error(`${red('error')} --frequency must be one of ${FREQUENCIES.join(', ')}.`);
177
227
  return 2;
178
228
  }
179
- const repo = detectRepo(root);
229
+ const { repo, path } = codebaseOf(root);
180
230
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI && !args.yes;
181
231
  const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
182
232
  const ask = async (q) => (rl ? rl.question(q) : '');
233
+ const where = path ? `${repo} ${dim(`(${path}/)`)}` : repo;
183
234
  try {
184
- const { spaces } = (await (await apiFetch('/api/spaces/mine')).json());
235
+ // Signed in first: everything after this is done as someone.
236
+ const { user } = (await (await apiFetch('/api/auth/session')).json().catch(() => ({ user: null })));
237
+ if (!user) {
238
+ if (!rl || !/^(|y|yes)$/i.test((await ask(` Sign in to DocuGate first? ${dim('(Y/n)')} `)).trim())) {
239
+ console.error(`${red('error')} Not signed in. Run docugate login, then docugate connect again.`);
240
+ return 1;
241
+ }
242
+ rl.pause();
243
+ await login(base());
244
+ rl.resume();
245
+ }
246
+ const { projects: choices } = (await (await apiFetch('/api/projects/choices')).json());
185
247
  console.log();
186
- console.log(` ${bold('Connect')} ${repo}`);
248
+ console.log(` ${bold('Connect')} ${where}`);
187
249
  console.log(` ${dim('Shared with DocuGate: this repository’s name, the tool running the CLI, the outcome of each sync, and the names,')}`);
188
250
  console.log(` ${dim('versions and lockfile paths of public packages, to check them for known vulnerabilities. Never your code.')}`);
189
251
  console.log();
190
- let spaceId = null;
191
- if (args.space) {
192
- const match = spaces.find((s) => s.slug === args.space);
193
- if (!match) {
194
- console.error(`${red('error')} You have no space called "${args.space}".`);
252
+ // The docs this codebase brings, as the repository sees the folder.
253
+ const config = loadConfig(root);
254
+ const docsDir = [path, config.docsDir].filter(Boolean).join('/');
255
+ const hasDocs = existsSync(join(root, config.docsDir));
256
+ let project;
257
+ if (args.project) {
258
+ project = pickProject(choices, args.project);
259
+ if (!project) {
260
+ console.error(`${red('error')} No project called "${args.project}" that you can connect to. Run docugate connect without --project to see them.`);
195
261
  return 1;
196
262
  }
197
- spaceId = match.id;
198
263
  }
199
- else if (rl && spaces.length) {
200
- spaces.forEach((s, i) => console.log(` ${i + 1}. ${s.name} ${dim(s.slug)}`));
201
- const n = Number((await ask(` Which documentation does this project update? ${dim('(number, or empty for none yet)')} `)).trim());
202
- spaceId = spaces[n - 1]?.id ?? null;
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') {
273
+ if (!hasDocs) {
274
+ console.log(` ${yellow('No docs folder here yet')} (${config.docsDir}/). Run docugate init in this folder to start one, then connect again.`);
275
+ return 1;
276
+ }
277
+ const name = (await ask(` What is the project called? [${repo.split('/').at(-1)}] `)).trim() || repo.split('/').at(-1);
278
+ const res = await apiFetch('/api/spaces', { method: 'POST', body: JSON.stringify({ repo, name, docsDir }) });
279
+ const { space } = (await res.json());
280
+ project = { ...space, org: null, sources: [{ repo, docsDir, mount: '' }], codebases: [] };
281
+ console.log(` ${green('✓')} Created ${space.name}, published from ${docsDir}/.`);
282
+ }
283
+ else {
284
+ project = choices[Number(answer) - 1];
285
+ }
203
286
  }
204
- else if (spaces.length === 1) {
205
- spaceId = spaces[0].id;
287
+ else if (choices.length === 1) {
288
+ project = choices[0];
206
289
  }
207
- const guessed = detectType(root);
208
- const type = args.type ?? (await choose('What kind of project is this?', PROJECT_TYPES, guessed, ask));
290
+ const type = args.type ?? (await choose('What is this codebase?', PROJECT_TYPES, detectType(root), ask));
209
291
  let backendDir = args.backendDir ?? '';
210
292
  if (type === 'monorepo' && !backendDir) {
211
293
  const found = detectBackendDir(root);
@@ -214,20 +296,61 @@ export async function connectCommand(root, args) {
214
296
  const frequency = args.frequency ?? (await choose('How often should docs be checked?', FREQUENCIES, 'weekly', ask));
215
297
  const res = await apiFetch('/api/projects', {
216
298
  method: 'POST',
217
- body: JSON.stringify({ repo, type, backendDir, frequency, spaceId, audit: !args.noAudit }),
299
+ body: JSON.stringify({ repo, path, type, backendDir, frequency, spaceId: project?.id ?? null, audit: !args.noAudit }),
218
300
  });
219
301
  const { token } = (await res.json());
220
- const path = TOKENS_FILE();
221
- mkdirSync(join(path, '..'), { recursive: true });
222
- writeFileSync(path, JSON.stringify({ ...readTokens(), [repo]: token }, null, 2) + '\n', { mode: 0o600 });
302
+ const file = TOKENS_FILE();
303
+ mkdirSync(join(file, '..'), { recursive: true });
304
+ writeFileSync(file, JSON.stringify({ ...readTokens(), [tokenKey(repo, path)]: token }, null, 2) + '\n', { mode: 0o600 });
305
+ // Its docs join the project's, unless the space already reads that folder.
306
+ let section = null;
307
+ const read = project?.sources.some((s) => s.repo.toLowerCase() === repo.toLowerCase() && s.docsDir === docsDir);
308
+ if (project && hasDocs && !read) {
309
+ section = args.section ?? sectionFor(type, project.sources);
310
+ for (let attempt = 0; attempt < 2; attempt++) {
311
+ try {
312
+ await apiFetch(`/api/spaces/${project.id}/sources`, {
313
+ method: 'POST',
314
+ body: JSON.stringify({ repo, docsDir, mount: section, label: path ? `${repo.split('/').at(-1)}/${path}` : repo.split('/').at(-1) }),
315
+ });
316
+ break;
317
+ }
318
+ catch (error) {
319
+ const code = codeOf(error);
320
+ // Two pages at one address: a section of its own settles it.
321
+ if (code === 'source_collision' && attempt === 0) {
322
+ 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;
324
+ continue;
325
+ }
326
+ section = null;
327
+ if (code === 'pro_required_sources')
328
+ console.log(` ${yellow('Its docs were not added.')} One project fed by several codebases needs Pro or an organization plan: ${base()}/pricing`);
329
+ else if (code === 'no_repo_access')
330
+ console.log(` ${yellow('Its docs were not added.')} Install the DocuGate GitHub App on ${repo} (${base()}/api/auth/github?install=1), then run docugate connect again.`);
331
+ else
332
+ console.log(` ${yellow('Its docs were not added.')} ${error.message}`);
333
+ break;
334
+ }
335
+ }
336
+ }
223
337
  console.log();
224
- console.log(` ${green('Connected.')} Change any of this in the dashboard, under Projects.`);
338
+ if (project) {
339
+ const docs = section === null ? (read ? 'Its docs were already published.' : hasDocs ? '' : `It has no docs folder (${config.docsDir}/) to publish yet.`) : section ? `Its docs appear under ${section}.` : 'Its docs are at the top of the space.';
340
+ console.log(` ${green('Connected')} ${where} (${type}) to ${bold(project.name)}. ${docs}`);
341
+ const others = project.codebases.filter((c) => !(c.repo === repo && c.path === path));
342
+ if (!others.length)
343
+ console.log(` ${dim('Run docugate connect in its other codebases, the frontend or the backend, to add them to the same project.')}`);
344
+ }
345
+ else {
346
+ console.log(` ${green('Connected')} ${where}. Choose its project in the dashboard, under Projects, or run docugate connect again.`);
347
+ }
225
348
  console.log();
226
349
  if (type === 'frontend')
227
350
  console.log(` ${dim('A frontend needs no server package.')}`);
228
351
  else {
229
- const where = type === 'monorepo' && backendDir ? `in ${backendDir}` : 'in your backend';
230
- console.log(` ${dim(`To check API keys, install the package ${where}:`)} npm i docugate`);
352
+ 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`);
231
354
  }
232
355
  console.log();
233
356
  console.log(` For scheduled syncs, add this token as a GitHub secret. It is shown once:`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docugate",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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": {