docugate 0.3.1 → 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.
@@ -0,0 +1,369 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, join, relative, resolve, sep } from 'node:path';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { apiFetch } from './api.js';
6
+ import { login } from './auth.js';
7
+ import { packageVersion } from './banner.js';
8
+ import { loadConfig } from './config.js';
9
+ import { guessRole } from './init.js';
10
+ import { scanLockfiles } from './lockfiles.js';
11
+ import { defaultGit } from './sync/changes.js';
12
+ import { repoFromRemote } from './sync/pr.js';
13
+ import { bold, dim, green, plural, red, yellow } from './ui.js';
14
+ // A repository connected to a DocuGate account. `docugate connect` links it, and
15
+ // from then on the dashboard decides which docs it updates, how often and what
16
+ // kind of project it is. Everything that talks to DocuGate on a project's behalf
17
+ // lives here, and none of it may ever be the reason a sync fails.
18
+ export const PROJECT_TYPES = ['frontend', 'backend', 'serverless', 'monorepo'];
19
+ export const FREQUENCIES = ['push', 'daily', 'weekly', 'off'];
20
+ const TOKENS_FILE = () => join(process.env.DOCUGATE_HOME ?? join(homedir(), '.docugate'), 'projects.json');
21
+ const base = () => process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site';
22
+ function readTokens() {
23
+ try {
24
+ return JSON.parse(readFileSync(TOKENS_FILE(), 'utf8'));
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ /** `owner/name` from the git remote, or the folder's name without one. */
31
+ export function detectRepo(root) {
32
+ try {
33
+ const repo = repoFromRemote(defaultGit(['remote', 'get-url', 'origin'], root));
34
+ if (repo)
35
+ return repo;
36
+ }
37
+ catch {
38
+ // Not a git repository, or no remote.
39
+ }
40
+ return basename(root);
41
+ }
42
+ /** The token for this codebase: CI's secret first, then what `connect` saved for this folder, then for its repository. */
43
+ export function projectToken(root, env = process.env) {
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;
49
+ }
50
+ /** A suggestion only. The person confirms it, and the dashboard can change it. */
51
+ export function detectType(root) {
52
+ const has = (name) => existsSync(join(root, name));
53
+ let workspaces = has('pnpm-workspace.yaml') || has('turbo.json') || has('nx.json');
54
+ try {
55
+ workspaces ||= Boolean(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).workspaces);
56
+ }
57
+ catch {
58
+ // No package.json, or an unreadable one.
59
+ }
60
+ const role = guessRole(root);
61
+ if (workspaces || role === 'both')
62
+ return 'monorepo';
63
+ if (['serverless.yml', 'serverless.ts', 'wrangler.toml', 'wrangler.jsonc', 'netlify.toml', 'vercel.json', 'template.yaml'].some(has))
64
+ return 'serverless';
65
+ return role === 'frontend' ? 'frontend' : 'backend';
66
+ }
67
+ /** Where the backend lives in a monorepo, which is where the key-check package installs. */
68
+ export function detectBackendDir(root) {
69
+ return ['backend', 'server', 'api', 'apps/api', 'apps/server', 'packages/api', 'packages/server'].find((d) => existsSync(join(root, d))) ?? '';
70
+ }
71
+ async function projectFetch(token, path, init = {}) {
72
+ return fetch(`${base()}/api/projects/self${path}`, {
73
+ ...init,
74
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
75
+ signal: AbortSignal.timeout(20_000),
76
+ });
77
+ }
78
+ /** This project's settings, or null: unlinked, offline, or a revoked token. */
79
+ export async function readSettings(token) {
80
+ try {
81
+ const res = await projectFetch(token, '');
82
+ return res.ok ? (await res.json()) : null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ /** Tells the dashboard what a run did. A failure here is not the run's failure. */
89
+ export async function reportRun(token, run) {
90
+ try {
91
+ await projectFetch(token, '/runs', { method: 'POST', body: JSON.stringify({ ...run, version: packageVersion() }) });
92
+ }
93
+ catch {
94
+ // The docs matter more than the log line.
95
+ }
96
+ }
97
+ export async function runAudit(root, token) {
98
+ const owner = detectRepo(root).split('/').length === 2 ? detectRepo(root).split('/')[0] : undefined;
99
+ if (loadConfig(root).config.audit === false) {
100
+ return { checked: false, reason: 'audit_off', findings: [], files: [], unchecked: [], packages: 0 };
101
+ }
102
+ const scan = scanLockfiles(root, owner);
103
+ const none = { findings: [], files: scan.files, unchecked: scan.unchecked, packages: scan.dependencies.length };
104
+ try {
105
+ const res = await projectFetch(token, '/dependencies', {
106
+ method: 'POST',
107
+ body: JSON.stringify({ dependencies: scan.dependencies, unchecked: scan.unchecked }),
108
+ });
109
+ if (!res.ok)
110
+ return { checked: false, reason: `status_${res.status}`, ...none };
111
+ const body = (await res.json());
112
+ return { ...none, ...body };
113
+ }
114
+ catch {
115
+ return { checked: false, reason: 'unreachable', ...none };
116
+ }
117
+ }
118
+ const SEVERITY_ORDER = ['critical', 'high', 'moderate', 'low', 'unknown'];
119
+ /** Critical and high by name with their fix; the rest as a count. */
120
+ export function printFindings(answer, log = console.log) {
121
+ if (!answer.checked) {
122
+ const why = answer.reason === 'audit_off' ? 'it is turned off for this project' : 'DocuGate could not finish the lookup';
123
+ log(` ${yellow('Dependencies not checked')}: ${why}. This does not mean they are safe.`);
124
+ return;
125
+ }
126
+ const urgent = answer.findings.filter((f) => f.severity === 'critical' || f.severity === 'high');
127
+ if (!answer.findings.length)
128
+ log(` ${green('No known vulnerabilities')} in ${plural(answer.packages, 'package')} checked.`);
129
+ else {
130
+ log(` ${red(plural(answer.findings.length, 'known vulnerability').replace('vulnerabilitys', 'vulnerabilities'))} in ${plural(answer.packages, 'package')}. Please fix these:`);
131
+ for (const f of urgent.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity))) {
132
+ log(` ${red(f.severity.padEnd(8))} ${bold(`${f.package}@${f.version}`)} ${f.fixedIn ? `upgrade to ${f.fixedIn}` : 'no fix published yet'}`);
133
+ log(` ${dim(`${f.summary} ${f.url}`)}`);
134
+ }
135
+ if (urgent.length < answer.findings.length)
136
+ log(` ${dim(`and ${answer.findings.length - urgent.length} of lower severity, listed in the dashboard's Security screen.`)}`);
137
+ }
138
+ for (const file of answer.unchecked)
139
+ log(` ${yellow('Not checked')}: ${file} could not be read, or uses a private package source.`);
140
+ }
141
+ /** `docugate audit`. Returns the exit code. */
142
+ export async function auditCommand(root, args, env = process.env) {
143
+ if (args.failOn && !['critical', 'high', 'moderate', 'low'].includes(args.failOn)) {
144
+ console.error(`${red('error')} --fail-on must be critical, high, moderate or low.`);
145
+ return 2;
146
+ }
147
+ const token = projectToken(root, env);
148
+ if (!token) {
149
+ console.error(`${red('error')} This project is not connected. Run docugate connect first.`);
150
+ return 1;
151
+ }
152
+ const answer = await runAudit(root, token);
153
+ if (args.json)
154
+ console.log(JSON.stringify(answer, null, 2));
155
+ else {
156
+ console.log();
157
+ console.log(` ${bold('Dependency check')} ${dim(`${plural(answer.files.length, 'lockfile')} read`)}`);
158
+ printFindings(answer);
159
+ console.log();
160
+ }
161
+ if (!answer.checked)
162
+ return args.failOn ? 1 : 0;
163
+ if (args.failOn) {
164
+ const limit = SEVERITY_ORDER.indexOf(args.failOn);
165
+ if (answer.findings.some((f) => SEVERITY_ORDER.indexOf(f.severity) <= limit))
166
+ return 1;
167
+ }
168
+ return 0;
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
+ }
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;
212
+ }
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
+ */
220
+ export async function connectCommand(root, args) {
221
+ if (args.type && !PROJECT_TYPES.includes(args.type)) {
222
+ console.error(`${red('error')} --type must be one of ${PROJECT_TYPES.join(', ')}.`);
223
+ return 2;
224
+ }
225
+ if (args.frequency && !FREQUENCIES.includes(args.frequency)) {
226
+ console.error(`${red('error')} --frequency must be one of ${FREQUENCIES.join(', ')}.`);
227
+ return 2;
228
+ }
229
+ const { repo, path } = codebaseOf(root);
230
+ 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) : '');
233
+ const where = path ? `${repo} ${dim(`(${path}/)`)}` : repo;
234
+ try {
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());
247
+ console.log();
248
+ console.log(` ${bold('Connect')} ${where}`);
249
+ console.log(` ${dim('Shared with DocuGate: this repository’s name, the tool running the CLI, the outcome of each sync, and the names,')}`);
250
+ console.log(` ${dim('versions and lockfile paths of public packages, to check them for known vulnerabilities. Never your code.')}`);
251
+ console.log();
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.`);
261
+ return 1;
262
+ }
263
+ }
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
+ }
286
+ }
287
+ else if (choices.length === 1) {
288
+ project = choices[0];
289
+ }
290
+ const type = args.type ?? (await choose('What is this codebase?', PROJECT_TYPES, detectType(root), ask));
291
+ let backendDir = args.backendDir ?? '';
292
+ if (type === 'monorepo' && !backendDir) {
293
+ const found = detectBackendDir(root);
294
+ backendDir = (await ask(` Which folder holds the backend? [${found || 'none'}] `)).trim() || found;
295
+ }
296
+ const frequency = args.frequency ?? (await choose('How often should docs be checked?', FREQUENCIES, 'weekly', ask));
297
+ const res = await apiFetch('/api/projects', {
298
+ method: 'POST',
299
+ body: JSON.stringify({ repo, path, type, backendDir, frequency, spaceId: project?.id ?? null, audit: !args.noAudit }),
300
+ });
301
+ const { token } = (await res.json());
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
+ }
337
+ console.log();
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
+ }
348
+ console.log();
349
+ if (type === 'frontend')
350
+ console.log(` ${dim('A frontend needs no server package.')}`);
351
+ else {
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`);
354
+ }
355
+ 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.')}`);
359
+ console.log();
360
+ return 0;
361
+ }
362
+ catch (error) {
363
+ console.error(`${red('error')} ${error.message}`);
364
+ return 1;
365
+ }
366
+ finally {
367
+ rl?.close();
368
+ }
369
+ }
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']);
@@ -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
- const result = await runSync(root, options, {
38
- ...deps,
39
- log: quiet ? undefined : (line) => out(` ${dim(line)}`),
40
- confirm: interactive && !options.yes ? ask : undefined,
41
- });
42
- return report(result, quiet);
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:`);
@@ -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;
@@ -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 on = o.freshness === 'push'
19
- ? [
20
- ' push:',
21
- ` branches: [${o.branch}]`,
22
- ' paths-ignore:',
23
- ...(docs ? [` - '${docs}/**'`] : []),
24
- ` - '.docugate/**'`,
25
- ]
26
- : [' schedule:', ` - cron: '${CRON[o.freshness]}'`];
27
- return `# Written by \`docugate sync init\`. It keeps your docs in step with your code.
28
- # ${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
29
- # edits. Nothing is merged for you. Change how often in docugate.json ("freshness"),
30
- # then run \`docugate sync init --force\`.
31
- name: DocuGate sync
32
-
33
- on:
34
- ${on.join('\n')}
35
- workflow_dispatch:
36
-
37
- permissions:
38
- contents: write
39
- pull-requests: write
40
-
41
- # One sync at a time, so two runs never fight over the same branch.
42
- concurrency:
43
- group: docugate-sync
44
- cancel-in-progress: false
45
-
46
- jobs:
47
- sync:
48
- # A sync's own commits and branch never start another sync.
49
- if: \${{ !contains(github.event.head_commit.message, '${SYNC_TAG}') && !startsWith(github.ref_name, '${SYNC_BRANCH_PREFIX}') }}
50
- runs-on: ubuntu-latest
51
- steps:
52
- - uses: actions/checkout@v4
53
- with:
54
- fetch-depth: 0
55
- - uses: actions/setup-node@v4
56
- with:
57
- node-version: 22
58
- - run: npx --yes docugate@${o.version} sync --pr --yes
59
- env:
60
- ${secret}: \${{ secrets.${secret} }}
61
- GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
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.1",
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": {