docugate 0.2.9 → 0.3.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.
Files changed (81) hide show
  1. package/README.md +200 -57
  2. package/dist/api.d.ts +44 -0
  3. package/dist/api.js +11 -5
  4. package/dist/args.d.ts +29 -0
  5. package/dist/args.js +121 -0
  6. package/dist/auth.d.ts +21 -0
  7. package/dist/banner.d.ts +13 -0
  8. package/dist/banner.js +62 -0
  9. package/dist/check.d.ts +22 -0
  10. package/dist/check.js +4 -3
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +201 -165
  13. package/dist/commands.d.ts +35 -0
  14. package/dist/commands.js +293 -0
  15. package/dist/config.d.ts +22 -0
  16. package/dist/config.js +43 -3
  17. package/dist/developers.d.ts +35 -0
  18. package/dist/developers.js +439 -0
  19. package/dist/init.d.ts +130 -0
  20. package/dist/init.js +93 -32
  21. package/dist/keys/core.d.ts +108 -0
  22. package/dist/keys/core.js +142 -0
  23. package/dist/keys/express.d.ts +21 -0
  24. package/dist/keys/express.js +27 -0
  25. package/dist/keys/fastify.d.ts +21 -0
  26. package/dist/keys/fastify.js +21 -0
  27. package/dist/keys/format.d.ts +11 -0
  28. package/dist/keys/format.js +46 -0
  29. package/dist/keys/hono.d.ts +19 -0
  30. package/dist/keys/hono.js +21 -0
  31. package/dist/keys/index.d.ts +13 -0
  32. package/dist/keys/index.js +12 -0
  33. package/dist/logo-pixels.d.ts +4 -0
  34. package/dist/logo-pixels.js +35 -0
  35. package/dist/logo.d.ts +19 -0
  36. package/dist/logo.js +68 -0
  37. package/dist/menu.d.ts +16 -0
  38. package/dist/menu.js +157 -0
  39. package/dist/openapi.d.ts +38 -0
  40. package/dist/openapi.js +2 -1
  41. package/dist/rules.d.ts +69 -0
  42. package/dist/rules.js +2 -1
  43. package/dist/select.d.ts +75 -0
  44. package/dist/select.js +215 -0
  45. package/dist/session.d.ts +46 -0
  46. package/dist/session.js +54 -0
  47. package/dist/spinner.d.ts +12 -0
  48. package/dist/spinner.js +12 -12
  49. package/dist/status.d.ts +46 -0
  50. package/dist/status.js +166 -0
  51. package/dist/sync/ai.d.ts +44 -0
  52. package/dist/sync/ai.js +147 -0
  53. package/dist/sync/apply.d.ts +25 -0
  54. package/dist/sync/apply.js +104 -0
  55. package/dist/sync/changes.d.ts +58 -0
  56. package/dist/sync/changes.js +167 -0
  57. package/dist/sync/command.d.ts +12 -0
  58. package/dist/sync/command.js +236 -0
  59. package/dist/sync/guard.d.ts +15 -0
  60. package/dist/sync/guard.js +21 -0
  61. package/dist/sync/hook.d.ts +10 -0
  62. package/dist/sync/hook.js +62 -0
  63. package/dist/sync/impact.d.ts +23 -0
  64. package/dist/sync/impact.js +93 -0
  65. package/dist/sync/pr.d.ts +37 -0
  66. package/dist/sync/pr.js +106 -0
  67. package/dist/sync/run.d.ts +50 -0
  68. package/dist/sync/run.js +124 -0
  69. package/dist/sync/state.d.ts +8 -0
  70. package/dist/sync/state.js +22 -0
  71. package/dist/sync/workflow.d.ts +20 -0
  72. package/dist/sync/workflow.js +84 -0
  73. package/dist/text.d.ts +11 -0
  74. package/dist/text.js +16 -0
  75. package/dist/tour-init.d.ts +61 -0
  76. package/dist/tour-install.d.ts +38 -0
  77. package/dist/tour-server.d.ts +21 -0
  78. package/dist/tour.d.ts +60 -0
  79. package/dist/ui.d.ts +29 -0
  80. package/dist/ui.js +63 -0
  81. package/package.json +67 -47
package/dist/select.js ADDED
@@ -0,0 +1,215 @@
1
+ import { emitKeypressEvents } from 'node:readline';
2
+ import { bold, colorOn, dim, gold, green } from './ui.js';
3
+ const strip = (s) => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
4
+ /** How well `text` matches a search: lower is better, null is no match. */
5
+ export function matchScore(text, query) {
6
+ const t = text.toLowerCase();
7
+ const q = query.toLowerCase().trim();
8
+ if (!q)
9
+ return 0;
10
+ const at = t.indexOf(q);
11
+ if (at >= 0)
12
+ return at === 0 ? 0 : t[at - 1] === ' ' ? 1 : 2;
13
+ // Letters in order, like "dsy" for "docs sync".
14
+ let i = 0;
15
+ for (const ch of t)
16
+ if (ch === q[i])
17
+ i++;
18
+ return i === q.length ? 5 : null;
19
+ }
20
+ export function filterChoices(choices, query) {
21
+ if (!query.trim())
22
+ return choices;
23
+ return choices
24
+ .map((c, order) => ({ c, order, score: matchScore(`${c.label} ${c.keywords ?? ''} ${c.status ?? ''}`, query) }))
25
+ .filter((x) => x.score !== null)
26
+ .sort((a, b) => a.score - b.score || a.order - b.order)
27
+ .map((x) => x.c);
28
+ }
29
+ /** The first choice Enter should land on: the recommended one, else the first that can be chosen. */
30
+ export function initialCursor(choices) {
31
+ const rec = choices.findIndex((c) => c.recommended && !c.disabled);
32
+ if (rec >= 0)
33
+ return rec;
34
+ const first = choices.findIndex((c) => !c.disabled);
35
+ return Math.max(0, first);
36
+ }
37
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
38
+ /**
39
+ * Moves to the next choice in a direction. A disabled one can be moved onto, so
40
+ * its description and the reason it is unavailable show; only Enter is refused.
41
+ */
42
+ function step(list, from, dir) {
43
+ return clamp(from + dir, 0, Math.max(0, list.length - 1));
44
+ }
45
+ /** One key press, applied to the picker's state. */
46
+ export function handleKey(choices, state, str, key, rows) {
47
+ const list = filterChoices(choices, state.query);
48
+ const move = (cursor) => {
49
+ const c = clamp(cursor, 0, Math.max(0, list.length - 1));
50
+ const top = c < state.top ? c : c >= state.top + rows ? c - rows + 1 : state.top;
51
+ return { state: { ...state, cursor: c, top } };
52
+ };
53
+ if (key.ctrl && (key.name === 'c' || key.name === 'd'))
54
+ return { done: 'cancelled' };
55
+ if (key.name === 'escape')
56
+ return state.query ? { state: { query: '', cursor: initialCursor(choices), top: 0 } } : { done: 'cancelled' };
57
+ if (key.name === 'return' || key.name === 'enter') {
58
+ const choice = list[state.cursor];
59
+ if (!choice)
60
+ return { state };
61
+ if (choice.disabled)
62
+ return { done: 'blocked', reason: choice.disabled, state };
63
+ return { done: 'chosen', choice };
64
+ }
65
+ if (key.name === 'up' || (key.ctrl && key.name === 'p'))
66
+ return move(step(list, state.cursor, -1));
67
+ if (key.name === 'down' || (key.ctrl && key.name === 'n'))
68
+ return move(step(list, state.cursor, 1));
69
+ if (key.name === 'pageup')
70
+ return move(state.cursor - rows);
71
+ if (key.name === 'pagedown')
72
+ return move(state.cursor + rows);
73
+ if (key.name === 'home')
74
+ return move(0);
75
+ if (key.name === 'end')
76
+ return move(list.length - 1);
77
+ if (key.name === 'backspace') {
78
+ const query = state.query.slice(0, -1);
79
+ return { state: { query, cursor: query ? 0 : initialCursor(choices), top: 0 } };
80
+ }
81
+ if (key.ctrl && key.name === 'u')
82
+ return { state: { query: '', cursor: initialCursor(choices), top: 0 } };
83
+ // Anything printable is part of the search.
84
+ if (str && !key.ctrl && !key.meta && /^[\x20-\x7e -￿]$/.test(str)) {
85
+ const query = state.query + str;
86
+ return { state: { query, cursor: 0, top: 0 } };
87
+ }
88
+ return { state };
89
+ }
90
+ const fit = (text, width) => {
91
+ const plain = strip(text);
92
+ if (plain.length <= width)
93
+ return text;
94
+ // Over width: cut the plain text. Color is not worth keeping on a line this long.
95
+ return `${plain.slice(0, Math.max(0, width - 1))}…`;
96
+ };
97
+ /** The picker as lines of text. */
98
+ export function renderPicker(choices, state, o) {
99
+ const list = filterChoices(choices, state.query);
100
+ const rule = dim('─'.repeat(Math.max(10, Math.min(o.width - 4, 78))));
101
+ const lines = [];
102
+ const w = o.width - 2;
103
+ if (o.title)
104
+ lines.push(` ${bold(o.title)}`);
105
+ lines.push(` ${rule}`);
106
+ lines.push(` ${dim('/')} ${state.query ? state.query : dim('Type to search')}${state.query ? gold('▌') : ''}`);
107
+ lines.push(` ${rule}`);
108
+ if (list.length === 0) {
109
+ lines.push('', ` ${dim('Nothing matches')} "${state.query}"`, '');
110
+ }
111
+ else {
112
+ const visible = list.slice(state.top, state.top + o.rows);
113
+ const labelWidth = Math.min(46, Math.max(...list.map((c) => c.label.length)) + 2);
114
+ lines.push(` ${state.top > 0 ? dim('↑ more above') : ' '}`.trimEnd());
115
+ visible.forEach((c, i) => {
116
+ const at = state.top + i === state.cursor;
117
+ const mark = c.recommended ? gold('●') : c.disabled ? dim('◌') : dim('·');
118
+ const pointer = at ? gold('❯') : ' ';
119
+ const label = c.label.padEnd(labelWidth);
120
+ const text = c.disabled ? dim(label) : at ? bold(gold(label)) : label;
121
+ const status = c.status ? (at ? c.status : dim(c.status)) : '';
122
+ lines.push(fit(` ${pointer} ${mark} ${text}${status}`, w));
123
+ });
124
+ lines.push(` ${state.top + o.rows < list.length ? dim('↓ more below') : ' '}`.trimEnd());
125
+ const focus = list[state.cursor];
126
+ const blurb = focus?.disabled ? `${focus.description ?? ''} ${dim(`(${focus.disabled})`)}`.trim() : focus?.description;
127
+ lines.push('', ...(blurb ? wrap(blurb, Math.min(w - 4, 76)).map((l) => ` ${dim('│')} ${l}`) : []));
128
+ }
129
+ if (o.note)
130
+ lines.push('', ` ${o.note}`);
131
+ if (o.legend !== false)
132
+ lines.push('', ` ${gold('●')} ${dim('Recommended')} ${dim('◌ Needs something first')}`);
133
+ lines.push('', ` ${dim(o.footer ?? '↑↓ select · type to search · ↵ confirm · esc cancel')}`);
134
+ return lines.map((l) => fit(l, o.width - 1));
135
+ }
136
+ function wrap(text, width) {
137
+ const out = [];
138
+ let line = '';
139
+ for (const word of text.split(/\s+/)) {
140
+ if ((line + ' ' + word).trim().length > width && line) {
141
+ out.push(line);
142
+ line = word;
143
+ }
144
+ else
145
+ line = `${line} ${word}`.trim();
146
+ }
147
+ if (line)
148
+ out.push(line);
149
+ return out;
150
+ }
151
+ /** Whether there is a real terminal to draw a picker in. */
152
+ export const canPick = (io = { stdin: process.stdin, stdout: process.stdout }) => Boolean(io.stdin.isTTY && io.stdout.isTTY) && typeof io.stdin.setRawMode === 'function' && !process.env.CI;
153
+ /**
154
+ * Draws the picker and resolves with the chosen value, or null if the person
155
+ * pressed escape or Ctrl+C. Draws in place and leaves one line behind.
156
+ */
157
+ export function select(options, io = { stdin: process.stdin, stdout: process.stdout }) {
158
+ const { stdin, stdout } = io;
159
+ const rows = Math.max(3, Math.min(options.rows ?? 8, (stdout.rows ?? 24) - 14));
160
+ const color = colorOn();
161
+ let state = { query: '', cursor: initialCursor(options.choices), top: 0 };
162
+ state.top = Math.max(0, state.cursor - rows + 1);
163
+ let drawn = 0;
164
+ let note = options.note;
165
+ const hint = (s) => (color ? s : strip(s));
166
+ const draw = () => {
167
+ const lines = renderPicker(options.choices, state, {
168
+ title: options.title,
169
+ width: stdout.columns ?? 80,
170
+ rows,
171
+ footer: options.footer,
172
+ note,
173
+ legend: options.legend,
174
+ });
175
+ stdout.write(`${drawn ? `\x1b[${drawn}A\r` : ''}\x1b[0J${hint(lines.join('\n'))}\n`);
176
+ drawn = lines.length;
177
+ };
178
+ const erase = () => stdout.write(`${drawn ? `\x1b[${drawn}A\r` : ''}\x1b[0J`);
179
+ return new Promise((resolve) => {
180
+ emitKeypressEvents(stdin);
181
+ const wasRaw = stdin.isRaw;
182
+ stdin.setRawMode(true);
183
+ stdin.resume();
184
+ stdout.write('\x1b[?25l');
185
+ const finish = (value, summary) => {
186
+ stdin.off('keypress', onKey);
187
+ stdin.setRawMode(Boolean(wasRaw));
188
+ stdin.pause();
189
+ erase();
190
+ stdout.write('\x1b[?25h');
191
+ if (summary)
192
+ stdout.write(` ${green('✓')} ${summary}\n`);
193
+ resolve(value);
194
+ };
195
+ const onKey = (str, key = {}) => {
196
+ const result = handleKey(options.choices, state, str, key, rows);
197
+ if ('done' in result) {
198
+ if (result.done === 'cancelled')
199
+ return finish(null);
200
+ if (result.done === 'chosen')
201
+ return finish(result.choice.value, result.choice.label);
202
+ note = `${gold('!')} ${result.reason}`;
203
+ state = result.state;
204
+ return draw();
205
+ }
206
+ note = options.note;
207
+ state = result.state;
208
+ draw();
209
+ };
210
+ stdin.on('keypress', onKey);
211
+ // A terminal closed mid-menu must still get its cursor back.
212
+ process.once('exit', () => stdout.write('\x1b[?25h'));
213
+ draw();
214
+ });
215
+ }
@@ -0,0 +1,46 @@
1
+ export type AccountState = {
2
+ state: 'signed-in';
3
+ login: string;
4
+ } | {
5
+ state: 'signed-out';
6
+ reason?: string;
7
+ }
8
+ /** A token is saved but DocuGate could not be reached to confirm it. */
9
+ | {
10
+ state: 'offline';
11
+ detail: string;
12
+ };
13
+ export type SessionDeps = {
14
+ getSessionFn?: () => Promise<{
15
+ user: {
16
+ githubLogin: string;
17
+ } | null;
18
+ }>;
19
+ hasToken?: () => boolean;
20
+ env?: NodeJS.ProcessEnv;
21
+ /** How long to wait for DocuGate before calling it offline. */
22
+ timeoutMs?: number;
23
+ };
24
+ export declare function accountState(deps?: SessionDeps): Promise<AccountState>;
25
+ export type EnsureOptions = SessionDeps & {
26
+ /** Whether anyone can be asked. False in CI, pipes and under --no-input. */
27
+ interactive: boolean;
28
+ ask?: (question: string) => Promise<boolean>;
29
+ loginFn?: (baseUrl: string) => Promise<unknown>;
30
+ say?: (line: string) => void;
31
+ /** What the account is needed for, to finish the sentence "…to ___". */
32
+ reason?: string;
33
+ };
34
+ export type Ensured = {
35
+ ok: true;
36
+ login: string;
37
+ } | {
38
+ ok: false;
39
+ why: string;
40
+ };
41
+ /**
42
+ * Makes sure there is a signed-in account before a command that needs one.
43
+ * Signed in: carry on. Not, and someone can answer: offer to connect, then do
44
+ * it. Not, and nobody can answer: say exactly what to do instead of hanging.
45
+ */
46
+ export declare function ensureSignedIn(options: EnsureOptions): Promise<Ensured>;
@@ -0,0 +1,54 @@
1
+ import { BASE_URL, getSession } from './api.js';
2
+ import { login, loadCredentials } from './auth.js';
3
+ import { hint } from './ui.js';
4
+ const withTimeout = (p, ms) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), ms).unref())]);
5
+ export async function accountState(deps = {}) {
6
+ const env = deps.env ?? process.env;
7
+ const hasToken = deps.hasToken ?? (() => Boolean(env.DOCUGATE_TOKEN || loadCredentials()));
8
+ if (!hasToken())
9
+ return { state: 'signed-out' };
10
+ try {
11
+ const { user } = await withTimeout((deps.getSessionFn ?? getSession)(), deps.timeoutMs ?? 3000);
12
+ return user ? { state: 'signed-in', login: user.githubLogin } : { state: 'signed-out', reason: 'Your saved sign-in has expired.' };
13
+ }
14
+ catch (error) {
15
+ const message = error.message;
16
+ // A 401 surfaces as "Run docugate login": the token is no good, not the network.
17
+ if (/docugate login/i.test(message))
18
+ return { state: 'signed-out', reason: 'Your saved sign-in has expired.' };
19
+ return { state: 'offline', detail: message };
20
+ }
21
+ }
22
+ /**
23
+ * Makes sure there is a signed-in account before a command that needs one.
24
+ * Signed in: carry on. Not, and someone can answer: offer to connect, then do
25
+ * it. Not, and nobody can answer: say exactly what to do instead of hanging.
26
+ */
27
+ export async function ensureSignedIn(options) {
28
+ const say = options.say ?? (() => { });
29
+ const first = await accountState(options);
30
+ if (first.state === 'signed-in')
31
+ return { ok: true, login: first.login };
32
+ if (first.state === 'offline') {
33
+ return { ok: false, why: `Could not reach DocuGate to confirm your sign-in (${first.detail}). Check your connection and try again.` };
34
+ }
35
+ const need = options.reason ? ` to ${options.reason}` : '';
36
+ if (!options.interactive || !options.ask) {
37
+ return { ok: false, why: `This needs a DocuGate account${need}. Run ${hint('login')}, or set DOCUGATE_TOKEN in CI.` };
38
+ }
39
+ if (first.reason)
40
+ say(first.reason);
41
+ if (!(await options.ask(`Connect your DocuGate account${need}?`))) {
42
+ return { ok: false, why: `This needs a DocuGate account${need}. Run ${hint('login')} when you are ready.` };
43
+ }
44
+ try {
45
+ await (options.loginFn ?? ((url) => login(url)))(BASE_URL);
46
+ }
47
+ catch (error) {
48
+ return { ok: false, why: `Sign-in did not finish: ${error.message}` };
49
+ }
50
+ const after = await accountState(options);
51
+ return after.state === 'signed-in'
52
+ ? { ok: true, login: after.login }
53
+ : { ok: false, why: 'Sign-in did not finish, so nothing was connected.' };
54
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A live "working on it" line for the waits in setup: a spinner, the elapsed
3
+ * time, and a status that changes every few seconds, so a minute of IBM Bob
4
+ * reading the code never looks like a frozen terminal.
5
+ *
6
+ * It runs in a small child process, so it keeps moving even while the main
7
+ * process is blocked waiting on Bob Shell. Only in a terminal; elsewhere it
8
+ * prints the first status once.
9
+ */
10
+ export declare function startSpinner(statuses: string[]): (finalLine?: string) => void;
11
+ /** Environment variables that look like an IBM Bob key under another name. */
12
+ export declare function bobKeyCandidates(env?: NodeJS.ProcessEnv): string[];
package/dist/spinner.js CHANGED
@@ -16,18 +16,18 @@ export function startSpinner(statuses) {
16
16
  process.stderr.write(`${finalLine}\n`);
17
17
  };
18
18
  }
19
- const script = `
20
- const statuses = JSON.parse(process.argv[1]);
21
- const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
22
- const start = Date.now();
23
- let i = 0;
24
- const cols = () => process.stderr.columns || 80;
25
- setInterval(() => {
26
- const secs = Math.floor((Date.now() - start) / 1000);
27
- const status = statuses[Math.min(Math.floor(secs / 6), statuses.length - 1)];
28
- const line = ' ' + frames[i++ % frames.length] + ' ' + status + '... ' + secs + 's';
29
- process.stderr.write('\\r' + line.slice(0, cols() - 1).padEnd(cols() - 1));
30
- }, 90);
19
+ const script = `
20
+ const statuses = JSON.parse(process.argv[1]);
21
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
22
+ const start = Date.now();
23
+ let i = 0;
24
+ const cols = () => process.stderr.columns || 80;
25
+ setInterval(() => {
26
+ const secs = Math.floor((Date.now() - start) / 1000);
27
+ const status = statuses[Math.min(Math.floor(secs / 6), statuses.length - 1)];
28
+ const line = ' ' + frames[i++ % frames.length] + ' ' + status + '... ' + secs + 's';
29
+ process.stderr.write('\\r' + line.slice(0, cols() - 1).padEnd(cols() - 1));
30
+ }, 90);
31
31
  `;
32
32
  const child = spawn(process.execPath, ['-e', script, JSON.stringify(statuses)], {
33
33
  stdio: ['ignore', 'ignore', 'inherit'],
@@ -0,0 +1,46 @@
1
+ import type { AccountState, SessionDeps } from './session.js';
2
+ import type { GitFn } from './sync/changes.js';
3
+ export type Next = {
4
+ command: string;
5
+ why: string;
6
+ done?: boolean;
7
+ };
8
+ export type Status = {
9
+ account: AccountState;
10
+ project: {
11
+ git: boolean;
12
+ setUp: boolean;
13
+ docsDir: string;
14
+ pages: number;
15
+ role?: string;
16
+ space?: string;
17
+ freshness: string;
18
+ };
19
+ sync: {
20
+ workflow: boolean;
21
+ hook: boolean;
22
+ key: 'anthropic' | 'openai' | null;
23
+ lastRunAt?: string;
24
+ /** Commits since the last sync, or null when it could not be worked out. */
25
+ commits: number | null;
26
+ /** Pages the deterministic pass thinks those commits touched. */
27
+ affected: number;
28
+ uncovered: number;
29
+ };
30
+ tour: {
31
+ screens: number;
32
+ pill: boolean;
33
+ };
34
+ next: Next;
35
+ };
36
+ export type StatusDeps = SessionDeps & {
37
+ git?: GitFn;
38
+ now?: Date;
39
+ };
40
+ export declare function gatherStatus(root: string, deps?: StatusDeps): Promise<Status>;
41
+ /** The one thing worth doing now, in the order a project grows. */
42
+ export declare function nextStep(s: Omit<Status, 'next'>): Next;
43
+ /** "3 days ago", for a time in the past. */
44
+ export declare function ago(iso: string | undefined, now?: Date): string;
45
+ /** The status as lines to print. */
46
+ export declare function renderStatus(s: Status, now?: Date): string[];
package/dist/status.js ADDED
@@ -0,0 +1,166 @@
1
+ import { loadConfig } from './config.js';
2
+ import { accountState } from './session.js';
3
+ import { collectChanges, defaultGit } from './sync/changes.js';
4
+ import { hookInstalled } from './sync/hook.js';
5
+ import { loadPages, scoreImpact } from './sync/impact.js';
6
+ import { readSyncState } from './sync/state.js';
7
+ import { workflowInstalled } from './sync/workflow.js';
8
+ import { hasPillSnippet } from './tour-install.js';
9
+ import { loadTours } from './tour.js';
10
+ import { dim, gold, green, hint, plural, yellow } from './ui.js';
11
+ export async function gatherStatus(root, deps = {}) {
12
+ const env = deps.env ?? process.env;
13
+ const git = deps.git ?? defaultGit;
14
+ const loaded = loadConfig(root);
15
+ const docsDir = loaded.docsDir;
16
+ let inGit = false;
17
+ try {
18
+ inGit = git(['rev-parse', '--is-inside-work-tree'], root).trim() === 'true';
19
+ }
20
+ catch {
21
+ /* not a repository */
22
+ }
23
+ const pages = (() => {
24
+ try {
25
+ return loadPages(root, docsDir);
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ })();
31
+ const state = readSyncState(root);
32
+ let commits = null;
33
+ let affected = 0;
34
+ let uncovered = 0;
35
+ if (inGit && loaded.found) {
36
+ try {
37
+ const changes = collectChanges(root, {
38
+ lastSha: state.lastSha,
39
+ freshness: loaded.config.freshness,
40
+ docsDir,
41
+ include: loaded.config.sync?.include,
42
+ exclude: loaded.config.sync?.exclude,
43
+ git,
44
+ now: deps.now,
45
+ budget: 20_000,
46
+ });
47
+ commits = changes.commits.length;
48
+ const impact = scoreImpact(pages, changes.files, loaded.config.sync?.map);
49
+ affected = impact.pages.length;
50
+ uncovered = impact.uncovered.length;
51
+ }
52
+ catch {
53
+ /* no commits yet, or history too short: nothing to report */
54
+ }
55
+ }
56
+ let screens = 0;
57
+ try {
58
+ screens = loadTours(root).length;
59
+ }
60
+ catch {
61
+ /* a broken tour file is `check`'s to report */
62
+ }
63
+ const account = await accountState({ ...deps, env });
64
+ const partial = {
65
+ account,
66
+ project: {
67
+ git: inGit,
68
+ setUp: loaded.found,
69
+ docsDir,
70
+ pages: pages.length,
71
+ role: loaded.config.role,
72
+ space: loaded.config.space,
73
+ freshness: loaded.config.freshness ?? 'off',
74
+ },
75
+ sync: {
76
+ workflow: workflowInstalled(root),
77
+ hook: inGit && hookInstalled(root, git),
78
+ key: env.ANTHROPIC_API_KEY ? 'anthropic' : env.OPENAI_API_KEY ? 'openai' : null,
79
+ lastRunAt: state.lastRunAt,
80
+ commits,
81
+ affected,
82
+ uncovered,
83
+ },
84
+ tour: { screens, pill: hasPillSnippet(root) },
85
+ };
86
+ return { ...partial, next: nextStep(partial) };
87
+ }
88
+ /** The one thing worth doing now, in the order a project grows. */
89
+ export function nextStep(s) {
90
+ if (s.account.state === 'signed-out') {
91
+ return { command: 'login', why: 'Connect your DocuGate account so your docs can be published' };
92
+ }
93
+ if (!s.project.setUp)
94
+ return { command: 'init', why: 'Set this repository up for DocuGate' };
95
+ if (s.project.git && s.project.freshness !== 'off' && !s.sync.workflow) {
96
+ return { command: 'sync init', why: `Keep the docs current automatically (${s.project.freshness})` };
97
+ }
98
+ if (s.project.git && s.sync.commits && s.sync.commits > 0) {
99
+ return {
100
+ command: 'sync',
101
+ why: s.sync.affected
102
+ ? `${plural(s.sync.affected, 'page')} may be out of date after ${plural(s.sync.commits, 'commit')}`
103
+ : `${plural(s.sync.commits, 'commit')} since the last sync`,
104
+ };
105
+ }
106
+ const frontend = s.project.role === 'frontend' || s.project.role === 'both';
107
+ if (frontend && s.tour.screens === 0)
108
+ return { command: 'tour init', why: 'Write the first tour of your app' };
109
+ if (frontend && !s.tour.pill)
110
+ return { command: 'tour install', why: 'Add the Tour button to your app' };
111
+ return { command: 'check', why: 'Everything looks current', done: true };
112
+ }
113
+ /** "3 days ago", for a time in the past. */
114
+ export function ago(iso, now = new Date()) {
115
+ if (!iso)
116
+ return 'never';
117
+ const ms = now.getTime() - new Date(iso).getTime();
118
+ if (!Number.isFinite(ms))
119
+ return 'never';
120
+ const h = Math.floor(ms / 3_600_000);
121
+ if (h < 1)
122
+ return 'just now';
123
+ if (h < 24)
124
+ return `${h}h ago`;
125
+ const d = Math.floor(h / 24);
126
+ return d === 1 ? 'yesterday' : d < 60 ? `${d} days ago` : `${Math.floor(d / 30)} months ago`;
127
+ }
128
+ const ok = green('✓');
129
+ const off = dim('–');
130
+ const warn = yellow('!');
131
+ /** The status as lines to print. */
132
+ export function renderStatus(s, now = new Date()) {
133
+ const row = (label, mark, text) => ` ${dim(label.padEnd(9))} ${mark} ${text}`;
134
+ const lines = [];
135
+ const a = s.account;
136
+ lines.push(a.state === 'signed-in'
137
+ ? row('Account', ok, `signed in as ${gold(`@${a.login}`)}`)
138
+ : a.state === 'offline'
139
+ ? row('Account', warn, `signed in, but DocuGate could not be reached ${dim(`(${a.detail})`)}`)
140
+ : row('Account', off, `not connected ${dim(`(${hint('login')})`)}`));
141
+ const p = s.project;
142
+ lines.push(!p.setUp
143
+ ? row('Docs', off, `not set up ${dim(`(${hint('init')})`)}`)
144
+ : row('Docs', p.pages ? ok : warn, `${plural(p.pages, 'page')} in ${p.docsDir}/${p.space ? ` ${dim('·')} space ${gold(p.space)}` : ` ${dim('· no space linked')}`}`));
145
+ const y = s.sync;
146
+ if (p.setUp) {
147
+ const bits = [
148
+ p.freshness === 'off' ? 'automatic sync off' : `checks ${p.freshness}`,
149
+ y.workflow ? 'workflow installed' : 'no workflow',
150
+ y.hook ? 'hook on' : null,
151
+ y.key ? `${y.key === 'anthropic' ? 'Anthropic' : 'OpenAI'} key found` : 'no AI key in this shell',
152
+ ].filter(Boolean);
153
+ lines.push(row('Sync', y.workflow ? ok : off, `${bits.join(dim(' · '))}`));
154
+ const behind = y.commits === null
155
+ ? dim('no history to compare yet')
156
+ : y.commits === 0
157
+ ? 'up to date with the code'
158
+ : `${plural(y.commits, 'commit')} since the last sync ${dim(`(${ago(y.lastRunAt, now)})`)}, ${y.affected ? `${plural(y.affected, 'page')} likely affected` : 'no page clearly affected'}`;
159
+ lines.push(row('', y.commits ? warn : ok, behind));
160
+ }
161
+ lines.push(s.tour.screens
162
+ ? row('Tour', s.tour.pill ? ok : warn, `${plural(s.tour.screens, 'screen')}${s.tour.pill ? ' · pill installed' : ` · pill not installed ${dim(`(${hint('tour install')})`)}`}`)
163
+ : row('Tour', off, dim('none yet')));
164
+ lines.push('', ` ${dim('Next'.padEnd(9))} ${gold(hint(s.next.command))} ${dim(`${s.next.done ? '' : '— '}${s.next.why}`)}`);
165
+ return lines;
166
+ }
@@ -0,0 +1,44 @@
1
+ import type { SyncConfig } from '../rules.js';
2
+ import type { Changes } from './changes.js';
3
+ import type { Impact, PageInfo } from './impact.js';
4
+ export type DocEdit = {
5
+ path: string;
6
+ action: 'update' | 'create';
7
+ content: string;
8
+ reason: string;
9
+ };
10
+ export type AiPlan = {
11
+ summary: string;
12
+ edits: DocEdit[];
13
+ };
14
+ export type Provider = {
15
+ name: 'anthropic' | 'openai';
16
+ model: string;
17
+ key: string;
18
+ baseUrl: string;
19
+ };
20
+ export type AiRequest = {
21
+ system: string;
22
+ user: string;
23
+ maxTokens: number;
24
+ };
25
+ /** One model call. Tests pass their own, so none of them touches a network. */
26
+ export type AiFn = (request: AiRequest) => Promise<string>;
27
+ /** Which provider and key to use: the configured one, else whichever key is set. */
28
+ export declare function resolveProvider(env: NodeJS.ProcessEnv, sync?: SyncConfig, modelOverride?: string): Provider | null;
29
+ export declare const KEY_HELP: string;
30
+ /** The call to a real provider. */
31
+ export declare function providerAiFn(provider: Provider): AiFn;
32
+ /** The words the model is given, with the untrusted diff fenced off. */
33
+ export declare function buildPrompt(input: {
34
+ changes: Changes;
35
+ impact: Impact;
36
+ pages: PageInfo[];
37
+ docsDir: string;
38
+ maxFiles: number;
39
+ nonce?: string;
40
+ }): string;
41
+ /** The model's answer, as edits. Anything that does not fit the shape is an error. */
42
+ export declare function parsePlan(text: string): AiPlan;
43
+ /** One call: the model is asked once, and the answer is read, never trusted. */
44
+ export declare function planEdits(aiFn: AiFn, input: Parameters<typeof buildPrompt>[0]): Promise<AiPlan>;