docugate 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /** The tool running the CLI, from what it sets in the environment. */
2
+ export declare function detectAgent(env?: NodeJS.ProcessEnv): string;
1
3
  /** Module-level default, captured at import time (used by CLI commands). */
2
4
  export declare const BASE_URL: string;
3
5
  export declare function apiFetch(path: string, init?: RequestInit): Promise<Response>;
package/dist/api.js CHANGED
@@ -1,4 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename } from 'node:path';
1
3
  import { loadCredentials, saveCredentials } from './auth.js';
4
+ const CLI_VERSION = (() => {
5
+ try {
6
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '';
7
+ }
8
+ catch {
9
+ return '';
10
+ }
11
+ })();
12
+ /** The tool running the CLI, from what it sets in the environment. */
13
+ export function detectAgent(env = process.env) {
14
+ if (env.CLAUDECODE)
15
+ return 'Claude Code';
16
+ if (env.CURSOR_TRACE_ID || env.CURSOR_AGENT)
17
+ return 'Cursor';
18
+ if (env.GITHUB_COPILOT_CLI || env.COPILOT_AGENT)
19
+ return 'Copilot';
20
+ if (env.CI)
21
+ return 'CI';
22
+ return 'Terminal';
23
+ }
2
24
  /** Module-level default, captured at import time (used by CLI commands). */
3
25
  export const BASE_URL = process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site';
4
26
  const ERROR_MESSAGES = {
@@ -24,6 +46,11 @@ export async function apiFetch(path, init = {}) {
24
46
  };
25
47
  if (creds) {
26
48
  headers['Authorization'] = `Bearer ${creds.token}`;
49
+ // What the dashboard's Agents screen lists: this folder's name and the tool
50
+ // running the CLI. No paths, no file contents.
51
+ headers['x-docugate-project'] = basename(process.cwd());
52
+ headers['x-docugate-agent'] = detectAgent();
53
+ headers['x-docugate-version'] = CLI_VERSION;
27
54
  }
28
55
  // Re-read the env at call time so tests that set DOCUGATE_URL after import work.
29
56
  const base = process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site';
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { parseArgs, UsageError } from './args.js';
7
+ import { auditCommand, connectCommand } from './project.js';
7
8
  import { banner, packageVersion } from './banner.js';
8
9
  import { check } from './check.js';
9
10
  import { COMMANDS, findCommand, renderCommandHelp, renderCompletion, renderGroupHelp, renderHelp } from './commands.js';
@@ -210,6 +211,21 @@ async function run(argv) {
210
211
  console.log(session.user ? `@${session.user.githubLogin}` : `Not signed in. Run ${hint('login')}.`);
211
212
  return;
212
213
  }
214
+ case 'connect': {
215
+ process.exitCode = await connectCommand(root, {
216
+ space: one('space'),
217
+ frequency: one('frequency'),
218
+ type: one('type'),
219
+ backendDir: one('backend-dir'),
220
+ noAudit: args.bools.has('no-audit'),
221
+ yes: args.bools.has('yes') || noInput,
222
+ });
223
+ return;
224
+ }
225
+ case 'audit': {
226
+ process.exitCode = await auditCommand(root, { failOn: one('fail-on'), json: args.bools.has('json') });
227
+ return;
228
+ }
213
229
  case 'developers': {
214
230
  if (noInput && !args.bools.has('yes'))
215
231
  fail('developers asks questions. Run it in a terminal, or add --yes to take every default.');
package/dist/commands.js CHANGED
@@ -163,6 +163,36 @@ export const COMMANDS = [
163
163
  examples: ['docugate logout'],
164
164
  account: 'none',
165
165
  },
166
+ {
167
+ name: 'connect',
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. ' +
171
+ '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
+ flags: [
173
+ { name: 'space', value: 'slug', description: 'The space this project updates' },
174
+ { name: 'frequency', value: 'when', description: 'How often docs are checked: push, daily, weekly or off' },
175
+ { name: 'type', value: 'kind', description: 'frontend, backend, serverless or monorepo (default: detected)' },
176
+ { name: 'backend-dir', value: 'folder', description: 'In a monorepo, the folder that holds the backend' },
177
+ { name: 'no-audit', description: 'Do not send package names to check for vulnerabilities' },
178
+ YES,
179
+ ],
180
+ examples: ['docugate connect', 'docugate connect --space handbook --frequency daily --type monorepo --backend-dir apps/api --yes'],
181
+ account: 'required',
182
+ },
183
+ {
184
+ name: 'audit',
185
+ group: 'Checks',
186
+ summary: 'Check this project’s packages for known vulnerabilities',
187
+ description: 'Reads your lockfiles without installing or running anything, and checks public packages against the OSV.dev vulnerability database. ' +
188
+ 'Private packages stay on your machine. It only warns, unless you ask it to fail with --fail-on.',
189
+ flags: [
190
+ { name: 'fail-on', value: 'severity', description: 'Exit 1 at this severity or above: critical, high, moderate or low' },
191
+ { name: 'json', description: 'Print the result as JSON' },
192
+ ],
193
+ examples: ['docugate audit', 'docugate audit --fail-on high'],
194
+ account: 'required',
195
+ },
166
196
  {
167
197
  name: 'whoami',
168
198
  group: 'Account',
package/dist/init.d.ts CHANGED
@@ -19,6 +19,7 @@ export declare function init(root: string, opts?: {
19
19
  dir?: string;
20
20
  title?: string;
21
21
  }): InitResult;
22
+ export declare const AGENTS_FILE = "AGENTS.md";
22
23
  export type Role = 'frontend' | 'backend' | 'both';
23
24
  /** Minimal I/O surface so the flow can be driven by tests without a terminal. */
24
25
  export interface IO {
package/dist/init.js CHANGED
@@ -38,12 +38,76 @@ export function init(root, opts = {}) {
38
38
  else {
39
39
  mkdirSync(base, { recursive: true });
40
40
  writeFileSync(join(base, 'index.md'), `# ${title}\n\nWelcome. This page is the landing page of your DocuGate space.\n\n` +
41
+ `New here? Read [Start here](start-here.md) first.\n\n` +
41
42
  `Every \`.md\` file in \`${docsDir}/\` becomes a page, and every folder becomes a\n` +
42
43
  `section in the sidebar. The first \`# heading\` in a file is its title.\n`);
43
44
  created.push(`${docsDir}/index.md`);
45
+ writeFileSync(join(base, 'start-here.md'), startHere(title));
46
+ created.push(`${docsDir}/start-here.md`);
47
+ }
48
+ // For the AI tools that work in this repository: where the docs are, and how
49
+ // they are written. Never touched if the repository already has one.
50
+ if (existsSync(join(root, AGENTS_FILE))) {
51
+ kept.push(AGENTS_FILE);
52
+ }
53
+ else {
54
+ writeFileSync(join(root, AGENTS_FILE), agentsGuide(title, docsDir));
55
+ created.push(AGENTS_FILE);
44
56
  }
45
57
  return { created, kept, docsDir };
46
58
  }
59
+ export const AGENTS_FILE = 'AGENTS.md';
60
+ /** The page a new reader opens first: what is here, and how to find the rest. */
61
+ function startHere(title) {
62
+ return `# Start here
63
+
64
+ This is the documentation for ${title}. Five minutes here saves an hour later.
65
+
66
+ ## What is in these docs
67
+
68
+ - **Guides** walk through one task from start to finish.
69
+ - **Reference** pages list every option, for looking things up.
70
+ - **Runbooks** say what to do when something breaks.
71
+
72
+ Replace this list with the sections your docs actually have.
73
+
74
+ ## Finding things
75
+
76
+ - Press \`/\` to search every page.
77
+ - The sidebar follows the folders in the repository, so a page lives where you
78
+ would look for it.
79
+
80
+ ## Getting help
81
+
82
+ Say where to ask questions: a channel, an email address, or a person.
83
+ `;
84
+ }
85
+ /** AGENTS.md: what an AI coding tool should know before it writes docs here. */
86
+ function agentsGuide(title, docsDir) {
87
+ return `# AGENTS.md
88
+
89
+ Guidance for AI coding tools working in ${title}.
90
+
91
+ ## Documentation
92
+
93
+ - The docs live in \`${docsDir}/\` and are published with DocuGate. Every push
94
+ updates the site; there is no build step.
95
+ - Every \`.md\` file is a page and every folder is a sidebar section. The first
96
+ \`# heading\` in a file is its title.
97
+ - \`${docsDir}/index.md\` is the landing page. \`${docsDir}/start-here.md\` is the
98
+ first page a new reader opens.
99
+ - When you change behaviour, update the page that describes it in the same
100
+ change. Prefer editing an existing page to adding a new one.
101
+ - Run \`docugate check\` before committing: it confirms every page parses and
102
+ every link resolves.
103
+
104
+ ## Writing style
105
+
106
+ - Short sentences, plain words, one idea per paragraph.
107
+ - Name commands, files and settings exactly, in backticks.
108
+ - Lead with the task the reader came to do, then the details.
109
+ `;
110
+ }
47
111
  export function makeDefaultIO() {
48
112
  const isTTY = Boolean(process.stdin.isTTY);
49
113
  return {
@@ -0,0 +1,24 @@
1
+ export type Dependency = {
2
+ ecosystem: 'npm' | 'PyPI' | 'Go';
3
+ name: string;
4
+ version: string;
5
+ /** Listed by the project itself. Null when the lockfile cannot say. */
6
+ direct?: boolean | null;
7
+ /** For an indirect package, the direct one that brings it in, when known. */
8
+ via?: string | null;
9
+ lockfile?: string;
10
+ };
11
+ export type LockfileScan = {
12
+ dependencies: Dependency[];
13
+ files: string[];
14
+ unchecked: string[];
15
+ };
16
+ type Parser = (text: string, ownOwner?: string) => Dependency[];
17
+ export declare const parsePackageLock: Parser;
18
+ export declare const parsePnpmLock: Parser;
19
+ export declare const parseYarnLock: Parser;
20
+ export declare const parseRequirements: Parser;
21
+ export declare const parseGoMod: Parser;
22
+ /** Every lockfile in the project, monorepo folders included, within a depth limit. */
23
+ export declare function scanLockfiles(root: string, ownOwner?: string): LockfileScan;
24
+ export {};
@@ -0,0 +1,203 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ const PUBLIC_NPM = /^https:\/\/registry\.(npmjs\.org|yarnpkg\.com)\//;
4
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next', '.venv', 'venv', '__pycache__', 'target', 'coverage']);
5
+ const MAX_DEPTH = 4;
6
+ const MAX_DIRS = 2000;
7
+ export const parsePackageLock = (text) => {
8
+ const lock = JSON.parse(text);
9
+ const out = [];
10
+ if (lock.packages) {
11
+ for (const [path, entry] of Object.entries(lock.packages)) {
12
+ const at = path.lastIndexOf('node_modules/');
13
+ if (at < 0 || entry.link || !entry.version || !entry.resolved || !PUBLIC_NPM.test(entry.resolved))
14
+ continue;
15
+ out.push({ ecosystem: 'npm', name: path.slice(at + 'node_modules/'.length), version: entry.version });
16
+ }
17
+ addViaFromLock(lock.packages, out);
18
+ }
19
+ else if (lock.dependencies) {
20
+ collectV1(lock.dependencies, out);
21
+ }
22
+ else {
23
+ throw new Error('no packages listed');
24
+ }
25
+ return out;
26
+ };
27
+ /** Walks down from each direct package, so an indirect one can name who brought it in. */
28
+ function addViaFromLock(packages, out) {
29
+ const root = packages[''];
30
+ const direct = Object.keys({ ...root?.dependencies, ...root?.devDependencies });
31
+ const via = new Map();
32
+ for (const top of direct) {
33
+ const queue = [top];
34
+ const seen = new Set(queue);
35
+ while (queue.length) {
36
+ const name = queue.shift();
37
+ for (const child of Object.keys(packages[`node_modules/${name}`]?.dependencies ?? {})) {
38
+ if (seen.has(child))
39
+ continue;
40
+ seen.add(child);
41
+ if (!via.has(child) && !direct.includes(child))
42
+ via.set(child, top);
43
+ queue.push(child);
44
+ }
45
+ }
46
+ }
47
+ for (const dep of out)
48
+ if (via.has(dep.name))
49
+ dep.via = via.get(dep.name);
50
+ }
51
+ function collectV1(deps, out) {
52
+ for (const [name, entry] of Object.entries(deps)) {
53
+ if (entry.version && entry.resolved && PUBLIC_NPM.test(entry.resolved))
54
+ out.push({ ecosystem: 'npm', name, version: entry.version });
55
+ if (entry.dependencies)
56
+ collectV1(entry.dependencies, out);
57
+ }
58
+ }
59
+ export const parsePnpmLock = (text) => {
60
+ const out = [];
61
+ const lines = text.split(/\r?\n/);
62
+ const start = lines.findIndex((l) => /^packages:\s*$/.test(l));
63
+ if (start < 0)
64
+ throw new Error('no packages section');
65
+ for (let i = start + 1; i < lines.length; i++) {
66
+ const line = lines[i];
67
+ if (/^\S/.test(line))
68
+ break;
69
+ const key = line.match(/^ {2}'?\/?((?:@[^/]+\/)?[^@/\s']+)[@/](\d[^:(\s']*)[^:]*'?:/);
70
+ if (!key)
71
+ continue;
72
+ // The next line says where it came from. A tarball means not the registry.
73
+ if (/tarball:|type: git|directory:/.test(lines[i + 1] ?? ''))
74
+ continue;
75
+ out.push({ ecosystem: 'npm', name: key[1], version: key[2] });
76
+ }
77
+ return out;
78
+ };
79
+ export const parseYarnLock = (text) => {
80
+ const out = [];
81
+ for (const block of text.split(/\r?\n\r?\n/)) {
82
+ const head = block.split(/\r?\n/).find((l) => /^\S.*:\s*$/.test(l) && !l.startsWith('#') && !l.startsWith('__metadata'));
83
+ if (!head)
84
+ continue;
85
+ const name = head.replace(/^"/, '').match(/^(@?[^@]+)@/)?.[1];
86
+ const version = block.match(/^\s+version:?\s+"?([^"\s]+)"?/m)?.[1];
87
+ if (!name || !version)
88
+ continue;
89
+ const resolved = block.match(/^\s+resolved\s+"([^"]+)"/m)?.[1];
90
+ const berry = block.match(/^\s+resolution:\s+"[^"]*@(npm):/m);
91
+ if (resolved ? !PUBLIC_NPM.test(resolved) : !berry)
92
+ continue;
93
+ out.push({ ecosystem: 'npm', name, version });
94
+ }
95
+ return out;
96
+ };
97
+ export const parseRequirements = (text) => {
98
+ // A custom index means these names may be private, so none of them leave.
99
+ if (/^\s*(--index-url|-i\s|--extra-index-url|--find-links)/m.test(text))
100
+ throw new Error('uses a custom package index');
101
+ const out = [];
102
+ for (const raw of text.split(/\r?\n/)) {
103
+ const m = raw.split('#')[0].trim().match(/^([A-Za-z0-9][\w.-]*)(?:\[[^\]]*\])?\s*==\s*([\w.!+-]+)/);
104
+ if (m)
105
+ out.push({ ecosystem: 'PyPI', name: m[1], version: m[2], direct: true });
106
+ }
107
+ return out;
108
+ };
109
+ export const parseGoMod = (text, ownOwner) => {
110
+ const out = [];
111
+ const replaced = new Set([...text.matchAll(/^\s*(?:replace\s+)?(\S+)(?:\s+v\S+)?\s+=>/gm)].map((m) => m[1]));
112
+ const add = (mod, version, direct) => {
113
+ if (replaced.has(mod))
114
+ return;
115
+ // Modules of the same GitHub account are as likely private as not.
116
+ if (ownOwner && mod.toLowerCase().startsWith(`github.com/${ownOwner.toLowerCase()}/`))
117
+ return;
118
+ out.push({ ecosystem: 'Go', name: mod, version: version.replace(/^v/, ''), direct });
119
+ };
120
+ let inBlock = false;
121
+ for (const raw of text.split(/\r?\n/)) {
122
+ const line = raw.split('//')[0].trim();
123
+ if (/^require\s*\($/.test(line))
124
+ inBlock = true;
125
+ else if (line === ')')
126
+ inBlock = false;
127
+ else {
128
+ const m = inBlock ? line.match(/^(\S+)\s+(v\S+)$/) : line.match(/^require\s+(\S+)\s+(v\S+)$/);
129
+ if (m)
130
+ add(m[1], m[2], !/\/\/\s*indirect/.test(raw));
131
+ }
132
+ }
133
+ return out;
134
+ };
135
+ const PARSERS = {
136
+ 'package-lock.json': parsePackageLock,
137
+ 'pnpm-lock.yaml': parsePnpmLock,
138
+ 'yarn.lock': parseYarnLock,
139
+ 'requirements.txt': parseRequirements,
140
+ 'go.mod': parseGoMod,
141
+ };
142
+ /**
143
+ * The packages this folder's package.json asks for. Null when it cannot say: none
144
+ * there, or a workspace root, whose real lists are in each package's own file.
145
+ */
146
+ function directNames(dir) {
147
+ try {
148
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
149
+ if (pkg.workspaces)
150
+ return null;
151
+ return new Set(Object.keys({ ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies }));
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ /** Every lockfile in the project, monorepo folders included, within a depth limit. */
158
+ export function scanLockfiles(root, ownOwner) {
159
+ const seen = new Map();
160
+ const files = [];
161
+ const unchecked = [];
162
+ let dirs = 0;
163
+ const visit = (dir, depth) => {
164
+ if (++dirs > MAX_DIRS)
165
+ return;
166
+ let names;
167
+ try {
168
+ names = readdirSync(dir);
169
+ }
170
+ catch {
171
+ return;
172
+ }
173
+ for (const name of names) {
174
+ const path = join(dir, name);
175
+ const parser = PARSERS[name];
176
+ if (parser) {
177
+ const shown = relative(root, path).replace(/\\/g, '/');
178
+ try {
179
+ const own = directNames(dir);
180
+ for (const dep of parser(readFileSync(path, 'utf8'), ownOwner)) {
181
+ const direct = dep.direct ?? (dep.ecosystem === 'npm' && own ? own.has(dep.name) : null);
182
+ seen.set(`${dep.ecosystem}:${dep.name}@${dep.version}`, { ...dep, direct, lockfile: shown });
183
+ }
184
+ files.push(shown);
185
+ }
186
+ catch {
187
+ unchecked.push(shown);
188
+ }
189
+ }
190
+ else if (depth < MAX_DEPTH && !SKIP_DIRS.has(name) && !name.startsWith('.')) {
191
+ try {
192
+ if (statSync(path).isDirectory())
193
+ visit(path, depth + 1);
194
+ }
195
+ catch {
196
+ // Gone or unreadable: not a lockfile we can speak for.
197
+ }
198
+ }
199
+ }
200
+ };
201
+ visit(root, 0);
202
+ return { dependencies: [...seen.values()], files, unchecked };
203
+ }
@@ -0,0 +1,72 @@
1
+ export declare const PROJECT_TYPES: readonly ["frontend", "backend", "serverless", "monorepo"];
2
+ export type ProjectType = (typeof PROJECT_TYPES)[number];
3
+ export declare const FREQUENCIES: readonly ["push", "daily", "weekly", "off"];
4
+ export type Frequency = (typeof FREQUENCIES)[number];
5
+ export type ProjectSettings = {
6
+ name: string;
7
+ type: ProjectType;
8
+ frequency: Frequency;
9
+ audit: boolean;
10
+ due: boolean;
11
+ space: {
12
+ owner: string;
13
+ slug: string;
14
+ name: string;
15
+ } | null;
16
+ };
17
+ export type Finding = {
18
+ id: string;
19
+ package: string;
20
+ version: string;
21
+ severity: 'critical' | 'high' | 'moderate' | 'low' | 'unknown';
22
+ summary: string;
23
+ fixedIn: string | null;
24
+ url: string;
25
+ };
26
+ /** `owner/name` from the git remote, or the folder's name without one. */
27
+ export declare function detectRepo(root: string): string;
28
+ /** The token for this repository: CI's secret first, then what `connect` saved. */
29
+ export declare function projectToken(root: string, env?: NodeJS.ProcessEnv): string | null;
30
+ /** A suggestion only. The person confirms it, and the dashboard can change it. */
31
+ export declare function detectType(root: string): ProjectType;
32
+ /** Where the backend lives in a monorepo, which is where the key-check package installs. */
33
+ export declare function detectBackendDir(root: string): string;
34
+ /** This project's settings, or null: unlinked, offline, or a revoked token. */
35
+ export declare function readSettings(token: string): Promise<ProjectSettings | null>;
36
+ export type RunReport = {
37
+ status: 'updated' | 'no_changes' | 'skipped' | 'failed';
38
+ pages?: number;
39
+ prUrl?: string;
40
+ commit?: string;
41
+ trigger?: string;
42
+ reason?: string;
43
+ };
44
+ /** Tells the dashboard what a run did. A failure here is not the run's failure. */
45
+ export declare function reportRun(token: string, run: RunReport): Promise<void>;
46
+ export type AuditAnswer = {
47
+ checked: boolean;
48
+ reason?: string;
49
+ findings: Finding[];
50
+ files: string[];
51
+ unchecked: string[];
52
+ packages: number;
53
+ };
54
+ export declare function runAudit(root: string, token: string): Promise<AuditAnswer>;
55
+ /** Critical and high by name with their fix; the rest as a count. */
56
+ export declare function printFindings(answer: AuditAnswer, log?: (line: string) => void): void;
57
+ export type AuditArgs = {
58
+ failOn?: string;
59
+ json: boolean;
60
+ };
61
+ /** `docugate audit`. Returns the exit code. */
62
+ export declare function auditCommand(root: string, args: AuditArgs, env?: NodeJS.ProcessEnv): Promise<number>;
63
+ export type ConnectArgs = {
64
+ space?: string;
65
+ frequency?: string;
66
+ type?: string;
67
+ backendDir?: string;
68
+ noAudit: boolean;
69
+ yes: boolean;
70
+ };
71
+ /** `docugate connect`. Returns the exit code. */
72
+ export declare function connectCommand(root: string, args: ConnectArgs): Promise<number>;