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
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
|
|
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
|
|
2
|
+
import { colorDepth, fg } from './logo.js';
|
|
3
3
|
import { colorOn, isMintty } from './ui.js';
|
|
4
|
-
// The banner: DOCUGATE in block
|
|
5
|
-
// owl, with the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
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
|
|
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 =
|
|
33
|
-
/**
|
|
34
|
-
|
|
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
|
|
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 ?
|
|
52
|
-
if (columns < WORDMARK_MIN_COLUMNS)
|
|
53
|
-
|
|
54
|
-
return ['', full.length <= columns ? full : ` DocuGate v${version}`, ''];
|
|
55
|
-
}
|
|
44
|
+
const dim = (t) => (color ? `[2m${t}[0m` : t);
|
|
45
|
+
if (columns < WORDMARK_MIN_COLUMNS)
|
|
46
|
+
return ['', ` DocuGate ${dim(`v${version}`)}`, ''];
|
|
56
47
|
const depth = colorDepth(env);
|
|
57
|
-
const
|
|
58
|
-
|
|
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}[0m` : 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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,
|
|
148
|
-
const answer = (await rl.question(
|
|
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) &&
|
|
208
|
+
return Number.isInteger(n) && choices[n - 1] ? choices[n - 1].value : null;
|
|
153
209
|
}
|
|
154
210
|
finally {
|
|
155
211
|
rl.close();
|