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/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/banner.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export declare const TAGLINE = "Tours and docs for your codebase";
2
- /** The wordmark as five plain lines, no color. */
2
+ /** The wordmark as two plain lines, no color. */
3
3
  export declare function wordmark(text?: string, gap?: number): string[];
4
4
  export declare function packageVersion(): string;
5
5
  export type BannerOptions = {
package/dist/banner.js CHANGED
@@ -1,43 +1,36 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { colorDepth, fg, logoBanner } from './logo.js';
2
+ import { colorDepth, fg } from './logo.js';
3
3
  import { colorOn, isMintty } from './ui.js';
4
- // The banner: DOCUGATE in block letters, shaded from cream to gold like the
5
- // owl, with the tagline and version under it. It adapts to where it is drawn:
6
- // beside the owl in a wide terminal, alone in a normal one, as a single line
7
- // in a narrow one, and without color when color is off. It is only ever
8
- // printed to a real terminal, so logs and CI never see it.
4
+ // The banner: DOCUGATE in a compact two-row block font, cream over gold
5
+ // like the owl, with the version beside it. Small on purpose: it sits above
6
+ // the menu, and the menu is what the person came for. A narrow terminal gets
7
+ // one line, and without color it is plain text. It is only ever printed to a
8
+ // person at a terminal, so logs and CI never see it.
9
9
  export const TAGLINE = 'Tours and docs for your codebase';
10
- const SITE = 'https://www.trydocugate.site';
11
- // A five-row block font, just the letters in the name. Every glyph is five
12
- // columns wide, so the wordmark's width is simply letters * 5 + gaps.
10
+ // Two rows per letter, three columns wide, drawn with half blocks.
13
11
  const GLYPHS = {
14
- D: ['████ ', '█ █', '█ █', '█ █', '████ '],
15
- O: [' ███ ', '█ █', '█ █', '█ █', ' ███ '],
16
- C: [' ████', '█ ', '█ ', '█ ', ' ████'],
17
- U: ['█ █', '█ █', '█ █', '█ █', ' ███ '],
18
- G: [' ████', '█ ', '█ ██', '█ █', ' ████'],
19
- A: [' ███ ', '█ █', '█████', '█ █', '█ █'],
20
- T: ['█████', ' █ ', ' █ ', ' █ ', ' █ '],
21
- E: ['█████', '█ ', '████ ', '█ ', '█████'],
12
+ D: ['█▀▄', '█▄▀'],
13
+ O: ['█▀█', '█▄█'],
14
+ C: ['█▀▀', '█▄▄'],
15
+ U: ['█ █', '█▄█'],
16
+ G: ['█▀▀', '█▄█'],
17
+ A: ['▄▀█', '█▀█'],
18
+ T: ['▀█▀', ' █ '],
19
+ E: ['█▀▀', '██▄'],
22
20
  };
23
- // Cream at the top, through gold, to deep gold at the bottom.
21
+ // Cream on the top row, gold on the bottom.
24
22
  const SHADES = [
25
23
  [245, 240, 227],
26
- [246, 222, 150],
27
24
  [244, 196, 63],
28
- [230, 175, 46],
29
- [217, 154, 30],
30
25
  ];
31
26
  /** Narrower than this and the wordmark would wrap, so one line is shown. */
32
- const WORDMARK_MIN_COLUMNS = 60;
33
- /** Wide enough for the owl beside the wordmark. */
34
- const OWL_MIN_COLUMNS = 96;
35
- /** The wordmark as five plain lines, no color. */
36
- export function wordmark(text = 'DOCUGATE', gap = 2) {
27
+ const WORDMARK_MIN_COLUMNS = 40;
28
+ /** The wordmark as two plain lines, no color. */
29
+ export function wordmark(text = 'DOCUGATE', gap = 1) {
37
30
  const letters = [...text.toUpperCase()].map((c) => GLYPHS[c]);
38
31
  if (letters.some((g) => !g))
39
32
  throw new Error(`The banner font has no glyph for every letter of "${text}".`);
40
- return [0, 1, 2, 3, 4].map((row) => letters.map((g) => g[row]).join(' '.repeat(gap)).trimEnd());
33
+ return [0, 1].map((row) => letters.map((g) => g[row]).join(' '.repeat(gap)).trimEnd());
41
34
  }
42
35
  export function packageVersion() {
43
36
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -48,15 +41,10 @@ export function banner(options = {}) {
48
41
  const {
49
42
  // mintty reports no width to a piped console; COLUMNS, or a usual window, stands in.
50
43
  columns = process.stdout.columns ?? (Number(process.env.COLUMNS) || (isMintty() ? 100 : 0)), color = colorOn(), version = packageVersion(), env = process.env, } = options;
51
- const dim = (t) => (color ? `\x1b[2m${t}\x1b[0m` : t);
52
- if (columns < WORDMARK_MIN_COLUMNS) {
53
- const full = ` DocuGate · ${TAGLINE} · v${version}`;
54
- return ['', full.length <= columns ? full : ` DocuGate v${version}`, ''];
55
- }
44
+ const dim = (t) => (color ? `${t}` : t);
45
+ if (columns < WORDMARK_MIN_COLUMNS)
46
+ return ['', ` DocuGate ${dim(`v${version}`)}`, ''];
56
47
  const depth = colorDepth(env);
57
- const art = wordmark().map((line, row) => (color ? `${fg(SHADES[row], depth)}${line}\x1b[0m` : line));
58
- const text = [...art, '', dim(TAGLINE), dim(`v${version} · ${SITE}`)];
59
- if (color && columns >= OWL_MIN_COLUMNS)
60
- return logoBanner(text, env);
61
- return ['', ...text.map((t) => (t ? ` ${t}` : '')), ''];
48
+ const [top, bottom] = wordmark().map((line, row) => (color ? `${fg(SHADES[row], depth)}${line}` : line));
49
+ return ['', ` ${top}`, ` ${bottom} ${dim(`v${version}`)}`, ''];
62
50
  }
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
+ }
package/dist/menu.d.ts CHANGED
@@ -7,10 +7,23 @@ export type Action = string[];
7
7
  export declare function buildChoices(s: Status, now?: Date): Choice<Action>[];
8
8
  /** The line under the list: who you are, and the one thing worth doing. */
9
9
  export declare function menuNote(s: Status, now?: Date): string;
10
+ /** Who is signed in: the one line the launch screen keeps above the list. */
11
+ export declare function whoNote(s: Status): string;
12
+ export type Section = 'main' | 'tours' | 'more';
13
+ /**
14
+ * The launch menu, short enough to see at once: the few things people come
15
+ * for on the first screen, the rest one step away under Tours and More. A step
16
+ * that cannot run yet is left out rather than shown greyed, so every line is
17
+ * something to do. Labels and rules come from buildChoices, written once.
18
+ */
19
+ export declare function buildMenu(s: Status, section: Section): Choice<Action>[];
10
20
  export type MenuDeps = {
11
21
  pick?: typeof select<Action>;
12
22
  /** For terminals that cannot do arrow keys: a numbered list instead. */
13
23
  numbered?: boolean;
14
24
  };
15
- /** Shows the menu and returns what was chosen, or null to quit. */
25
+ /**
26
+ * Shows the menu and returns the command chosen, or null to quit. Tours and
27
+ * More open their own short list, with Back to return.
28
+ */
16
29
  export declare function runMenu(status: Status, deps?: MenuDeps): Promise<Action | null>;
package/dist/menu.js CHANGED
@@ -130,26 +130,82 @@ export function menuNote(s, now = new Date()) {
130
130
  }
131
131
  return dim(bits.join(' · '));
132
132
  }
133
- /** Shows the menu and returns what was chosen, or null to quit. */
133
+ /** Who is signed in: the one line the launch screen keeps above the list. */
134
+ export function whoNote(s) {
135
+ if (s.account.state === 'signed-in')
136
+ return `signed in as ${gold(`@${s.account.login}`)}`;
137
+ return dim(s.account.state === 'offline' ? 'offline' : 'not signed in');
138
+ }
139
+ /** Values that move around the menu rather than run a command. */
140
+ const GO = { tours: ['@tours'], more: ['@more'], back: ['@back'], quit: ['@quit'] };
141
+ /**
142
+ * The launch menu, short enough to see at once: the few things people come
143
+ * for on the first screen, the rest one step away under Tours and More. A step
144
+ * that cannot run yet is left out rather than shown greyed, so every line is
145
+ * something to do. Labels and rules come from buildChoices, written once.
146
+ */
147
+ export function buildMenu(s, section) {
148
+ const all = buildChoices(s);
149
+ const pick = (...commands) => commands
150
+ .map((command) => all.find((c) => c.value.join(' ') === command))
151
+ .filter((c) => Boolean(c && !c.disabled))
152
+ .map((c) => ({ ...c, status: undefined, recommended: c.recommended }));
153
+ if (section === 'tours') {
154
+ return [...pick('tour init', 'tour install', 'tour serve'), { value: [...GO.back], label: 'Back' }];
155
+ }
156
+ if (section === 'more') {
157
+ const hook = all.find((c) => c.value[0] === 'sync' && c.value[1] === 'hook');
158
+ return [
159
+ ...pick('sync --dry-run', 'sync init', ...(hook ? [hook.value.join(' ')] : []), 'status', 'openapi', 'help'),
160
+ { value: [...GO.back], label: 'Back' },
161
+ ];
162
+ }
163
+ const signedIn = s.account.state === 'signed-in';
164
+ return [
165
+ ...pick('developers', 'init', 'sync', 'check'),
166
+ { value: [...GO.tours], label: 'Tours' },
167
+ { value: [...GO.more], label: 'More' },
168
+ ...pick(signedIn ? 'logout' : 'login'),
169
+ { value: [...GO.quit], label: 'Quit' },
170
+ ];
171
+ }
172
+ /**
173
+ * Shows the menu and returns the command chosen, or null to quit. Tours and
174
+ * More open their own short list, with Back to return.
175
+ */
134
176
  export async function runMenu(status, deps = {}) {
135
- const choices = buildChoices(status);
136
- if (deps.numbered)
137
- return numbered(choices, status);
138
177
  const pick = deps.pick ?? select;
139
- return pick({ title: 'What next?', choices, note: menuNote(status), rows: 8 });
178
+ let section = 'main';
179
+ for (;;) {
180
+ const choices = buildMenu(status, section);
181
+ const note = section === 'main' ? whoNote(status) : dim(section === 'tours' ? 'Tours' : 'More');
182
+ const chosen = deps.numbered ? await numbered(choices, note) : await pick({ choices, note, minimal: true });
183
+ if (!chosen || chosen[0] === GO.quit[0])
184
+ return null;
185
+ if (chosen[0] === GO.back[0])
186
+ section = 'main';
187
+ else if (chosen[0] === GO.tours[0])
188
+ section = 'tours';
189
+ else if (chosen[0] === GO.more[0])
190
+ section = 'more';
191
+ else
192
+ return chosen;
193
+ }
140
194
  }
141
- async function numbered(choices, status) {
142
- const usable = choices.filter((c) => !c.disabled);
143
- console.log(`\n ${bold('What next?')} ${menuNote(status)}\n`);
144
- usable.forEach((c, i) => console.log(` ${gold(String(i + 1).padStart(2))} ${c.label}${c.recommended ? gold(' ●') : ''}${c.status ? dim(` ${c.status}`) : ''}`));
195
+ async function numbered(choices, note) {
196
+ console.log(`
197
+ ${note}
198
+ `);
199
+ choices.forEach((c, i) => console.log(` ${dim(String(i + 1).padStart(2))} ${c.label}`));
145
200
  const rl = createInterface({ input: process.stdin, output: process.stdout });
146
201
  try {
147
- const def = Math.max(0, usable.findIndex((c) => c.recommended)) + 1;
148
- const answer = (await rl.question(`\n Choose 1-${usable.length}, or q to quit ${dim(`[${def}]`)}: `)).trim().toLowerCase();
202
+ const def = Math.max(0, choices.findIndex((c) => c.recommended)) + 1;
203
+ const answer = (await rl.question(`
204
+ ${dim(`Choose 1-${choices.length} [${def}]`)}: `)).trim().toLowerCase();
149
205
  if (answer === 'q')
150
206
  return null;
151
207
  const n = answer === '' ? def : Number(answer);
152
- return Number.isInteger(n) && usable[n - 1] ? usable[n - 1].value : null;
208
+ return Number.isInteger(n) && choices[n - 1] ? choices[n - 1].value : null;
153
209
  }
154
210
  finally {
155
211
  rl.close();