docugate 0.3.0 → 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/README.md +496 -494
- package/dist/api.d.ts +2 -0
- package/dist/api.js +27 -0
- package/dist/banner.d.ts +1 -1
- package/dist/banner.js +25 -37
- package/dist/cli.js +16 -0
- package/dist/commands.js +30 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +64 -0
- package/dist/lockfiles.d.ts +24 -0
- package/dist/lockfiles.js +203 -0
- package/dist/menu.d.ts +14 -1
- package/dist/menu.js +68 -12
- package/dist/project.d.ts +72 -0
- package/dist/project.js +246 -0
- package/dist/rules.d.ts +2 -0
- package/dist/rules.js +1 -1
- package/dist/select.d.ts +7 -1
- package/dist/select.js +36 -3
- package/dist/sync/command.js +45 -7
- package/dist/sync/workflow.d.ts +2 -0
- package/dist/sync/workflow.js +51 -45
- package/package.json +1 -1
|
@@ -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>;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { apiFetch } from './api.js';
|
|
6
|
+
import { packageVersion } from './banner.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { guessRole } from './init.js';
|
|
9
|
+
import { scanLockfiles } from './lockfiles.js';
|
|
10
|
+
import { defaultGit } from './sync/changes.js';
|
|
11
|
+
import { repoFromRemote } from './sync/pr.js';
|
|
12
|
+
import { bold, dim, green, plural, red, yellow } from './ui.js';
|
|
13
|
+
// A repository connected to a DocuGate account. `docugate connect` links it, and
|
|
14
|
+
// from then on the dashboard decides which docs it updates, how often and what
|
|
15
|
+
// kind of project it is. Everything that talks to DocuGate on a project's behalf
|
|
16
|
+
// lives here, and none of it may ever be the reason a sync fails.
|
|
17
|
+
export const PROJECT_TYPES = ['frontend', 'backend', 'serverless', 'monorepo'];
|
|
18
|
+
export const FREQUENCIES = ['push', 'daily', 'weekly', 'off'];
|
|
19
|
+
const TOKENS_FILE = () => join(process.env.DOCUGATE_HOME ?? join(homedir(), '.docugate'), 'projects.json');
|
|
20
|
+
const base = () => process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site';
|
|
21
|
+
function readTokens() {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(TOKENS_FILE(), 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** `owner/name` from the git remote, or the folder's name without one. */
|
|
30
|
+
export function detectRepo(root) {
|
|
31
|
+
try {
|
|
32
|
+
const repo = repoFromRemote(defaultGit(['remote', 'get-url', 'origin'], root));
|
|
33
|
+
if (repo)
|
|
34
|
+
return repo;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Not a git repository, or no remote.
|
|
38
|
+
}
|
|
39
|
+
return basename(root);
|
|
40
|
+
}
|
|
41
|
+
/** The token for this repository: CI's secret first, then what `connect` saved. */
|
|
42
|
+
export function projectToken(root, env = process.env) {
|
|
43
|
+
return env.DOCUGATE_PROJECT_TOKEN || readTokens()[detectRepo(root)] || null;
|
|
44
|
+
}
|
|
45
|
+
/** A suggestion only. The person confirms it, and the dashboard can change it. */
|
|
46
|
+
export function detectType(root) {
|
|
47
|
+
const has = (name) => existsSync(join(root, name));
|
|
48
|
+
let workspaces = has('pnpm-workspace.yaml') || has('turbo.json') || has('nx.json');
|
|
49
|
+
try {
|
|
50
|
+
workspaces ||= Boolean(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).workspaces);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No package.json, or an unreadable one.
|
|
54
|
+
}
|
|
55
|
+
const role = guessRole(root);
|
|
56
|
+
if (workspaces || role === 'both')
|
|
57
|
+
return 'monorepo';
|
|
58
|
+
if (['serverless.yml', 'serverless.ts', 'wrangler.toml', 'wrangler.jsonc', 'netlify.toml', 'vercel.json', 'template.yaml'].some(has))
|
|
59
|
+
return 'serverless';
|
|
60
|
+
return role === 'frontend' ? 'frontend' : 'backend';
|
|
61
|
+
}
|
|
62
|
+
/** Where the backend lives in a monorepo, which is where the key-check package installs. */
|
|
63
|
+
export function detectBackendDir(root) {
|
|
64
|
+
return ['backend', 'server', 'api', 'apps/api', 'apps/server', 'packages/api', 'packages/server'].find((d) => existsSync(join(root, d))) ?? '';
|
|
65
|
+
}
|
|
66
|
+
async function projectFetch(token, path, init = {}) {
|
|
67
|
+
return fetch(`${base()}/api/projects/self${path}`, {
|
|
68
|
+
...init,
|
|
69
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
70
|
+
signal: AbortSignal.timeout(20_000),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** This project's settings, or null: unlinked, offline, or a revoked token. */
|
|
74
|
+
export async function readSettings(token) {
|
|
75
|
+
try {
|
|
76
|
+
const res = await projectFetch(token, '');
|
|
77
|
+
return res.ok ? (await res.json()) : null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Tells the dashboard what a run did. A failure here is not the run's failure. */
|
|
84
|
+
export async function reportRun(token, run) {
|
|
85
|
+
try {
|
|
86
|
+
await projectFetch(token, '/runs', { method: 'POST', body: JSON.stringify({ ...run, version: packageVersion() }) });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// The docs matter more than the log line.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function runAudit(root, token) {
|
|
93
|
+
const owner = detectRepo(root).split('/').length === 2 ? detectRepo(root).split('/')[0] : undefined;
|
|
94
|
+
if (loadConfig(root).config.audit === false) {
|
|
95
|
+
return { checked: false, reason: 'audit_off', findings: [], files: [], unchecked: [], packages: 0 };
|
|
96
|
+
}
|
|
97
|
+
const scan = scanLockfiles(root, owner);
|
|
98
|
+
const none = { findings: [], files: scan.files, unchecked: scan.unchecked, packages: scan.dependencies.length };
|
|
99
|
+
try {
|
|
100
|
+
const res = await projectFetch(token, '/dependencies', {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
body: JSON.stringify({ dependencies: scan.dependencies, unchecked: scan.unchecked }),
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok)
|
|
105
|
+
return { checked: false, reason: `status_${res.status}`, ...none };
|
|
106
|
+
const body = (await res.json());
|
|
107
|
+
return { ...none, ...body };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { checked: false, reason: 'unreachable', ...none };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const SEVERITY_ORDER = ['critical', 'high', 'moderate', 'low', 'unknown'];
|
|
114
|
+
/** Critical and high by name with their fix; the rest as a count. */
|
|
115
|
+
export function printFindings(answer, log = console.log) {
|
|
116
|
+
if (!answer.checked) {
|
|
117
|
+
const why = answer.reason === 'audit_off' ? 'it is turned off for this project' : 'DocuGate could not finish the lookup';
|
|
118
|
+
log(` ${yellow('Dependencies not checked')}: ${why}. This does not mean they are safe.`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const urgent = answer.findings.filter((f) => f.severity === 'critical' || f.severity === 'high');
|
|
122
|
+
if (!answer.findings.length)
|
|
123
|
+
log(` ${green('No known vulnerabilities')} in ${plural(answer.packages, 'package')} checked.`);
|
|
124
|
+
else {
|
|
125
|
+
log(` ${red(plural(answer.findings.length, 'known vulnerability').replace('vulnerabilitys', 'vulnerabilities'))} in ${plural(answer.packages, 'package')}. Please fix these:`);
|
|
126
|
+
for (const f of urgent.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity))) {
|
|
127
|
+
log(` ${red(f.severity.padEnd(8))} ${bold(`${f.package}@${f.version}`)} ${f.fixedIn ? `upgrade to ${f.fixedIn}` : 'no fix published yet'}`);
|
|
128
|
+
log(` ${dim(`${f.summary} ${f.url}`)}`);
|
|
129
|
+
}
|
|
130
|
+
if (urgent.length < answer.findings.length)
|
|
131
|
+
log(` ${dim(`and ${answer.findings.length - urgent.length} of lower severity, listed in the dashboard's Security screen.`)}`);
|
|
132
|
+
}
|
|
133
|
+
for (const file of answer.unchecked)
|
|
134
|
+
log(` ${yellow('Not checked')}: ${file} could not be read, or uses a private package source.`);
|
|
135
|
+
}
|
|
136
|
+
/** `docugate audit`. Returns the exit code. */
|
|
137
|
+
export async function auditCommand(root, args, env = process.env) {
|
|
138
|
+
if (args.failOn && !['critical', 'high', 'moderate', 'low'].includes(args.failOn)) {
|
|
139
|
+
console.error(`${red('error')} --fail-on must be critical, high, moderate or low.`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
const token = projectToken(root, env);
|
|
143
|
+
if (!token) {
|
|
144
|
+
console.error(`${red('error')} This project is not connected. Run docugate connect first.`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
const answer = await runAudit(root, token);
|
|
148
|
+
if (args.json)
|
|
149
|
+
console.log(JSON.stringify(answer, null, 2));
|
|
150
|
+
else {
|
|
151
|
+
console.log();
|
|
152
|
+
console.log(` ${bold('Dependency check')} ${dim(`${plural(answer.files.length, 'lockfile')} read`)}`);
|
|
153
|
+
printFindings(answer);
|
|
154
|
+
console.log();
|
|
155
|
+
}
|
|
156
|
+
if (!answer.checked)
|
|
157
|
+
return args.failOn ? 1 : 0;
|
|
158
|
+
if (args.failOn) {
|
|
159
|
+
const limit = SEVERITY_ORDER.indexOf(args.failOn);
|
|
160
|
+
if (answer.findings.some((f) => SEVERITY_ORDER.indexOf(f.severity) <= limit))
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
async function choose(question, options, fallback, ask) {
|
|
166
|
+
const answer = (await ask(` ${question} ${dim(`(${options.join(', ')})`)} [${fallback}] `)).trim().toLowerCase();
|
|
167
|
+
return options.includes(answer) ? answer : fallback;
|
|
168
|
+
}
|
|
169
|
+
/** `docugate connect`. Returns the exit code. */
|
|
170
|
+
export async function connectCommand(root, args) {
|
|
171
|
+
if (args.type && !PROJECT_TYPES.includes(args.type)) {
|
|
172
|
+
console.error(`${red('error')} --type must be one of ${PROJECT_TYPES.join(', ')}.`);
|
|
173
|
+
return 2;
|
|
174
|
+
}
|
|
175
|
+
if (args.frequency && !FREQUENCIES.includes(args.frequency)) {
|
|
176
|
+
console.error(`${red('error')} --frequency must be one of ${FREQUENCIES.join(', ')}.`);
|
|
177
|
+
return 2;
|
|
178
|
+
}
|
|
179
|
+
const repo = detectRepo(root);
|
|
180
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI && !args.yes;
|
|
181
|
+
const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
182
|
+
const ask = async (q) => (rl ? rl.question(q) : '');
|
|
183
|
+
try {
|
|
184
|
+
const { spaces } = (await (await apiFetch('/api/spaces/mine')).json());
|
|
185
|
+
console.log();
|
|
186
|
+
console.log(` ${bold('Connect')} ${repo}`);
|
|
187
|
+
console.log(` ${dim('Shared with DocuGate: this repository’s name, the tool running the CLI, the outcome of each sync, and the names,')}`);
|
|
188
|
+
console.log(` ${dim('versions and lockfile paths of public packages, to check them for known vulnerabilities. Never your code.')}`);
|
|
189
|
+
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}".`);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
spaceId = match.id;
|
|
198
|
+
}
|
|
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;
|
|
203
|
+
}
|
|
204
|
+
else if (spaces.length === 1) {
|
|
205
|
+
spaceId = spaces[0].id;
|
|
206
|
+
}
|
|
207
|
+
const guessed = detectType(root);
|
|
208
|
+
const type = args.type ?? (await choose('What kind of project is this?', PROJECT_TYPES, guessed, ask));
|
|
209
|
+
let backendDir = args.backendDir ?? '';
|
|
210
|
+
if (type === 'monorepo' && !backendDir) {
|
|
211
|
+
const found = detectBackendDir(root);
|
|
212
|
+
backendDir = (await ask(` Which folder holds the backend? [${found || 'none'}] `)).trim() || found;
|
|
213
|
+
}
|
|
214
|
+
const frequency = args.frequency ?? (await choose('How often should docs be checked?', FREQUENCIES, 'weekly', ask));
|
|
215
|
+
const res = await apiFetch('/api/projects', {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
body: JSON.stringify({ repo, type, backendDir, frequency, spaceId, audit: !args.noAudit }),
|
|
218
|
+
});
|
|
219
|
+
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 });
|
|
223
|
+
console.log();
|
|
224
|
+
console.log(` ${green('Connected.')} Change any of this in the dashboard, under Projects.`);
|
|
225
|
+
console.log();
|
|
226
|
+
if (type === 'frontend')
|
|
227
|
+
console.log(` ${dim('A frontend needs no server package.')}`);
|
|
228
|
+
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`);
|
|
231
|
+
}
|
|
232
|
+
console.log();
|
|
233
|
+
console.log(` For scheduled syncs, add this token as a GitHub secret. It is shown once:`);
|
|
234
|
+
console.log(` gh secret set DOCUGATE_PROJECT_TOKEN --body ${token}`);
|
|
235
|
+
console.log(` ${dim('Then run docugate sync init --force so the workflow follows the dashboard.')}`);
|
|
236
|
+
console.log();
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
console.error(`${red('error')} ${error.message}`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
rl?.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type DocugateConfig = {
|
|
|
49
49
|
title?: string;
|
|
50
50
|
role?: 'frontend' | 'backend' | 'both';
|
|
51
51
|
/** How often DocuGate checks the docs still match the code. */
|
|
52
|
+
/** False keeps package names on this machine: no vulnerability check. */
|
|
53
|
+
audit?: boolean;
|
|
52
54
|
freshness?: 'push' | 'daily' | 'weekly' | 'off';
|
|
53
55
|
/** The DocuGate space the docs publish to, as owner/slug. */
|
|
54
56
|
space?: string;
|
package/dist/rules.js
CHANGED
|
@@ -59,4 +59,4 @@ export function firstHeading(content) {
|
|
|
59
59
|
return heading ? heading[1].trim() : null;
|
|
60
60
|
}
|
|
61
61
|
export const FRESHNESS_VALUES = ['push', 'daily', 'weekly', 'off'];
|
|
62
|
-
export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'role', 'freshness', 'space', 'sidebar', 'sync', 'api']);
|
|
62
|
+
export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'role', 'freshness', 'audit', 'space', 'sidebar', 'sync', 'api']);
|
package/dist/select.d.ts
CHANGED
|
@@ -41,7 +41,9 @@ export type KeyResult<T> = {
|
|
|
41
41
|
state: PickerState;
|
|
42
42
|
};
|
|
43
43
|
/** One key press, applied to the picker's state. */
|
|
44
|
-
export declare function handleKey<T>(choices: Choice<T>[], state: PickerState, str: string | undefined, key: Key, rows: number
|
|
44
|
+
export declare function handleKey<T>(choices: Choice<T>[], state: PickerState, str: string | undefined, key: Key, rows: number,
|
|
45
|
+
/** False for a short menu: letters do not search, a number picks, q quits. */
|
|
46
|
+
search?: boolean): KeyResult<T>;
|
|
45
47
|
export type RenderOptions = {
|
|
46
48
|
title?: string;
|
|
47
49
|
width: number;
|
|
@@ -49,6 +51,8 @@ export type RenderOptions = {
|
|
|
49
51
|
footer?: string;
|
|
50
52
|
note?: string;
|
|
51
53
|
legend?: boolean;
|
|
54
|
+
/** Just the note and a numbered list: no search, rules, description, legend or key hints. */
|
|
55
|
+
minimal?: boolean;
|
|
52
56
|
};
|
|
53
57
|
/** The picker as lines of text. */
|
|
54
58
|
export declare function renderPicker<T>(choices: Choice<T>[], state: PickerState, o: RenderOptions): string[];
|
|
@@ -65,6 +69,8 @@ export type SelectOptions<T> = {
|
|
|
65
69
|
/** How many choices show at once. */
|
|
66
70
|
rows?: number;
|
|
67
71
|
legend?: boolean;
|
|
72
|
+
/** The short form: see renderPicker. Every choice shows; a number picks one. */
|
|
73
|
+
minimal?: boolean;
|
|
68
74
|
};
|
|
69
75
|
/** Whether there is a real terminal to draw a picker in. */
|
|
70
76
|
export declare const canPick: (io?: PickerIO) => boolean;
|
package/dist/select.js
CHANGED
|
@@ -43,8 +43,21 @@ function step(list, from, dir) {
|
|
|
43
43
|
return clamp(from + dir, 0, Math.max(0, list.length - 1));
|
|
44
44
|
}
|
|
45
45
|
/** One key press, applied to the picker's state. */
|
|
46
|
-
export function handleKey(choices, state, str, key, rows
|
|
46
|
+
export function handleKey(choices, state, str, key, rows,
|
|
47
|
+
/** False for a short menu: letters do not search, a number picks, q quits. */
|
|
48
|
+
search = true) {
|
|
47
49
|
const list = filterChoices(choices, state.query);
|
|
50
|
+
if (!search && str && !key.ctrl && !key.meta) {
|
|
51
|
+
if (str === 'q')
|
|
52
|
+
return { done: 'cancelled' };
|
|
53
|
+
const n = Number(str);
|
|
54
|
+
if (Number.isInteger(n) && n >= 1 && n <= list.length) {
|
|
55
|
+
const choice = list[n - 1];
|
|
56
|
+
return choice.disabled ? { done: 'blocked', reason: choice.disabled, state: { ...state, cursor: n - 1 } } : { done: 'chosen', choice };
|
|
57
|
+
}
|
|
58
|
+
if (key.name !== 'return' && key.name !== 'enter')
|
|
59
|
+
return { state };
|
|
60
|
+
}
|
|
48
61
|
const move = (cursor) => {
|
|
49
62
|
const c = clamp(cursor, 0, Math.max(0, list.length - 1));
|
|
50
63
|
const top = c < state.top ? c : c >= state.top + rows ? c - rows + 1 : state.top;
|
|
@@ -94,9 +107,28 @@ const fit = (text, width) => {
|
|
|
94
107
|
// Over width: cut the plain text. Color is not worth keeping on a line this long.
|
|
95
108
|
return `${plain.slice(0, Math.max(0, width - 1))}…`;
|
|
96
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* The short form: the note, then every choice on its own numbered line. Short
|
|
112
|
+
* enough never to scroll, and every line cut to the width so none can wrap,
|
|
113
|
+
* which is what kept the redraw in step with what is on screen.
|
|
114
|
+
*/
|
|
115
|
+
function renderMinimal(list, state, o) {
|
|
116
|
+
const lines = [];
|
|
117
|
+
if (o.note)
|
|
118
|
+
lines.push(` ${o.note}`, '');
|
|
119
|
+
list.forEach((c, i) => {
|
|
120
|
+
const at = i === state.cursor;
|
|
121
|
+
const n = dim(String(i + 1).padStart(2));
|
|
122
|
+
const label = at ? bold(gold(c.label)) : c.disabled ? dim(c.label) : c.label;
|
|
123
|
+
lines.push(` ${at ? gold('❯') : ' '} ${n} ${label}`);
|
|
124
|
+
});
|
|
125
|
+
return lines.map((l) => fit(l, o.width - 1));
|
|
126
|
+
}
|
|
97
127
|
/** The picker as lines of text. */
|
|
98
128
|
export function renderPicker(choices, state, o) {
|
|
99
129
|
const list = filterChoices(choices, state.query);
|
|
130
|
+
if (o.minimal)
|
|
131
|
+
return renderMinimal(list, state, o);
|
|
100
132
|
const rule = dim('─'.repeat(Math.max(10, Math.min(o.width - 4, 78))));
|
|
101
133
|
const lines = [];
|
|
102
134
|
const w = o.width - 2;
|
|
@@ -156,7 +188,7 @@ export const canPick = (io = { stdin: process.stdin, stdout: process.stdout }) =
|
|
|
156
188
|
*/
|
|
157
189
|
export function select(options, io = { stdin: process.stdin, stdout: process.stdout }) {
|
|
158
190
|
const { stdin, stdout } = io;
|
|
159
|
-
const rows = Math.max(3, Math.min(options.rows ?? 8, (stdout.rows ?? 24) - 14));
|
|
191
|
+
const rows = options.minimal ? options.choices.length : Math.max(3, Math.min(options.rows ?? 8, (stdout.rows ?? 24) - 14));
|
|
160
192
|
const color = colorOn();
|
|
161
193
|
let state = { query: '', cursor: initialCursor(options.choices), top: 0 };
|
|
162
194
|
state.top = Math.max(0, state.cursor - rows + 1);
|
|
@@ -171,6 +203,7 @@ export function select(options, io = { stdin: process.stdin, stdout: process.std
|
|
|
171
203
|
footer: options.footer,
|
|
172
204
|
note,
|
|
173
205
|
legend: options.legend,
|
|
206
|
+
minimal: options.minimal,
|
|
174
207
|
});
|
|
175
208
|
stdout.write(`${drawn ? `\x1b[${drawn}A\r` : ''}\x1b[0J${hint(lines.join('\n'))}\n`);
|
|
176
209
|
drawn = lines.length;
|
|
@@ -193,7 +226,7 @@ export function select(options, io = { stdin: process.stdin, stdout: process.std
|
|
|
193
226
|
resolve(value);
|
|
194
227
|
};
|
|
195
228
|
const onKey = (str, key = {}) => {
|
|
196
|
-
const result = handleKey(options.choices, state, str, key, rows);
|
|
229
|
+
const result = handleKey(options.choices, state, str, key, rows, !options.minimal);
|
|
197
230
|
if ('done' in result) {
|
|
198
231
|
if (result.done === 'cancelled')
|
|
199
232
|
return finish(null);
|
package/dist/sync/command.js
CHANGED
|
@@ -10,6 +10,7 @@ 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
14
|
import { writeWorkflow, WORKFLOW_FILE } from './workflow.js';
|
|
14
15
|
const out = (line = '') => console.log(line);
|
|
15
16
|
/** `docugate sync ...`. Returns the exit code. */
|
|
@@ -34,12 +35,37 @@ export async function syncCommand(root, args, deps = {}) {
|
|
|
34
35
|
if (args.maxFiles && !(Number.isInteger(options.maxFiles) && options.maxFiles > 0)) {
|
|
35
36
|
throw new SyncError('--max-files needs a whole number above zero.');
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
// A dry run and the git hook stay local: free, private and quick.
|
|
39
|
+
const token = options.dryRun || quiet ? null : projectToken(root, deps.env ?? process.env);
|
|
40
|
+
const settings = token ? await readSettings(token) : null;
|
|
41
|
+
const trigger = (deps.env ?? process.env).GITHUB_EVENT_NAME ?? 'manual';
|
|
42
|
+
// Only an automatic wake-up waits for its turn. Asking by hand always runs.
|
|
43
|
+
if (token && settings && !settings.due && (trigger === 'push' || trigger === 'schedule')) {
|
|
44
|
+
out(` ${dim(`Not due: this project syncs ${settings.frequency === 'off' ? 'never (off in the dashboard)' : settings.frequency}.`)}`);
|
|
45
|
+
await reportRun(token, { status: 'skipped', trigger });
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
let result;
|
|
49
|
+
try {
|
|
50
|
+
result = await runSync(root, options, {
|
|
51
|
+
...deps,
|
|
52
|
+
log: quiet ? undefined : (line) => out(` ${dim(line)}`),
|
|
53
|
+
confirm: interactive && !options.yes ? ask : undefined,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (token)
|
|
58
|
+
await reportRun(token, { status: 'failed', trigger, commit: headCommit(root), reason: error instanceof SyncError ? error.message : 'The sync stopped with an unexpected error.' });
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const code = report(result, quiet);
|
|
62
|
+
if (token) {
|
|
63
|
+
await reportRun(token, runOutcome(result, trigger, headCommit(root)));
|
|
64
|
+
// An audit that fails or finds something warns. It never fails the sync.
|
|
65
|
+
if (settings?.audit)
|
|
66
|
+
printFindings(await runAudit(root, token));
|
|
67
|
+
}
|
|
68
|
+
return code;
|
|
43
69
|
}
|
|
44
70
|
catch (error) {
|
|
45
71
|
// A hook must never be the reason a commit looks broken.
|
|
@@ -52,6 +78,18 @@ export async function syncCommand(root, args, deps = {}) {
|
|
|
52
78
|
throw error;
|
|
53
79
|
}
|
|
54
80
|
}
|
|
81
|
+
function headCommit(root) {
|
|
82
|
+
try {
|
|
83
|
+
return defaultGit(['rev-parse', '--short', 'HEAD'], root).trim();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function runOutcome(result, trigger, commit) {
|
|
90
|
+
const pages = result.written.length;
|
|
91
|
+
return { status: pages ? 'updated' : 'no_changes', pages, prUrl: result.pr?.url, trigger, commit };
|
|
92
|
+
}
|
|
55
93
|
async function ask(question) {
|
|
56
94
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
57
95
|
try {
|
|
@@ -169,7 +207,7 @@ function syncInit(root, args) {
|
|
|
169
207
|
/* not a git repository: the workflow still gets written with main */
|
|
170
208
|
}
|
|
171
209
|
}
|
|
172
|
-
const result = writeWorkflow(root, { freshness, docsDir: loaded.docsDir, branch, version: packageVersion(), provider: loaded.config.sync?.provider }, args.bools.has('force'));
|
|
210
|
+
const result = writeWorkflow(root, { freshness, docsDir: loaded.docsDir, branch, version: packageVersion(), provider: loaded.config.sync?.provider, managed: Boolean(projectToken(root)) }, args.bools.has('force'));
|
|
173
211
|
out();
|
|
174
212
|
if (result.status === 'off') {
|
|
175
213
|
out(` ${yellow('freshness is "off"')}, so no workflow was written. Pick how often docs should be checked:`);
|
package/dist/sync/workflow.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type WorkflowOptions = {
|
|
|
8
8
|
/** The version to run, so a new release never changes what CI does by surprise. */
|
|
9
9
|
version: string;
|
|
10
10
|
provider?: 'anthropic' | 'openai';
|
|
11
|
+
/** Connected to DocuGate: it wakes often, and the dashboard decides whether a run is due. */
|
|
12
|
+
managed?: boolean;
|
|
11
13
|
};
|
|
12
14
|
/** The workflow file, or null when `freshness` is "off". */
|
|
13
15
|
export declare function buildWorkflow(o: WorkflowOptions): string | null;
|
package/dist/sync/workflow.js
CHANGED
|
@@ -11,54 +11,60 @@ const CRON = {
|
|
|
11
11
|
};
|
|
12
12
|
/** The workflow file, or null when `freshness` is "off". */
|
|
13
13
|
export function buildWorkflow(o) {
|
|
14
|
-
if (o.freshness === 'off')
|
|
14
|
+
if (o.freshness === 'off' && !o.managed)
|
|
15
15
|
return null;
|
|
16
16
|
const secret = o.provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
|
17
17
|
const docs = o.docsDir === '.' ? null : o.docsDir.replace(/^\/|\/$/g, '');
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
${on.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
18
|
+
const push = [
|
|
19
|
+
' push:',
|
|
20
|
+
` branches: [${o.branch}]`,
|
|
21
|
+
' paths-ignore:',
|
|
22
|
+
...(docs ? [` - '${docs}/**'`] : []),
|
|
23
|
+
` - '.docugate/**'`,
|
|
24
|
+
];
|
|
25
|
+
const on = o.managed
|
|
26
|
+
? [...push, ' schedule:', ` - cron: '${CRON.daily}'`]
|
|
27
|
+
: o.freshness === 'push'
|
|
28
|
+
? push
|
|
29
|
+
: [' schedule:', ` - cron: '${CRON[o.freshness]}'`];
|
|
30
|
+
const when = o.managed
|
|
31
|
+
? `Wakes on every push and every day; the DocuGate dashboard decides whether a sync is due.`
|
|
32
|
+
: null;
|
|
33
|
+
return `# Written by \`docugate sync init\`. It keeps your docs in step with your code.
|
|
34
|
+
# ${when ?? ''}${when ? '' : o.freshness === 'push' ? 'Runs on every push' : o.freshness === 'daily' ? 'Runs every day' : 'Runs every Monday'}, reads what changed, and opens a pull request with the docs
|
|
35
|
+
# edits. Nothing is merged for you. Change how often in docugate.json ("freshness"),
|
|
36
|
+
# then run \`docugate sync init --force\`.
|
|
37
|
+
name: DocuGate sync
|
|
38
|
+
|
|
39
|
+
on:
|
|
40
|
+
${on.join('\n')}
|
|
41
|
+
workflow_dispatch:
|
|
42
|
+
|
|
43
|
+
permissions:
|
|
44
|
+
contents: write
|
|
45
|
+
pull-requests: write
|
|
46
|
+
|
|
47
|
+
# One sync at a time, so two runs never fight over the same branch.
|
|
48
|
+
concurrency:
|
|
49
|
+
group: docugate-sync
|
|
50
|
+
cancel-in-progress: false
|
|
51
|
+
|
|
52
|
+
jobs:
|
|
53
|
+
sync:
|
|
54
|
+
# A sync's own commits and branch never start another sync.
|
|
55
|
+
if: \${{ !contains(github.event.head_commit.message, '${SYNC_TAG}') && !startsWith(github.ref_name, '${SYNC_BRANCH_PREFIX}') }}
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
steps:
|
|
58
|
+
- uses: actions/checkout@v4
|
|
59
|
+
with:
|
|
60
|
+
fetch-depth: 0
|
|
61
|
+
- uses: actions/setup-node@v4
|
|
62
|
+
with:
|
|
63
|
+
node-version: 22
|
|
64
|
+
- run: npx --yes docugate@${o.version} sync --pr --yes
|
|
65
|
+
env:
|
|
66
|
+
${secret}: \${{ secrets.${secret} }}
|
|
67
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}${o.managed ? '\n DOCUGATE_PROJECT_TOKEN: \${{ secrets.DOCUGATE_PROJECT_TOKEN }}' : ''}
|
|
62
68
|
`;
|
|
63
69
|
}
|
|
64
70
|
/** Write the workflow. An existing one that differs is left alone unless `force`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docugate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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": {
|