peaks-cli 1.2.8 → 1.2.9
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 +12 -0
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/project-commands.js +1 -1
- package/dist/src/cli/commands/scan-commands.js +22 -0
- package/dist/src/services/memory/project-memory-service.d.ts +1 -1
- package/dist/src/services/memory/project-memory-service.js +52 -23
- package/dist/src/services/scan/libraries-service.d.ts +24 -0
- package/dist/src/services/scan/libraries-service.js +419 -0
- package/dist/src/services/scan/libraries-types.d.ts +59 -0
- package/dist/src/services/scan/libraries-types.js +9 -0
- package/dist/src/services/skills/skill-runbook-service.js +34 -1
- package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
- package/dist/src/shared/change-id.d.ts +30 -0
- package/dist/src/shared/change-id.js +40 -6
- package/dist/src/shared/paths.d.ts +1 -1
- package/dist/src/shared/paths.js +2 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/schemas/library-breaking-changes.data.json +141 -0
- package/schemas/library-breaking-changes.meta.json +6 -0
- package/schemas/library-breaking-changes.schema.json +50 -0
- package/skills/peaks-qa/SKILL.md +12 -0
- package/skills/peaks-rd/SKILL.md +145 -2
- package/skills/peaks-solo/SKILL.md +76 -316
- package/skills/peaks-solo/references/runbook.md +166 -0
- package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
- package/skills/peaks-solo-resume/SKILL.md +81 -0
- package/skills/peaks-solo-status/SKILL.md +120 -0
- package/skills/peaks-solo-test/SKILL.md +84 -0
- package/skills/peaks-txt/SKILL.md +8 -5
package/README.md
CHANGED
|
@@ -13,6 +13,18 @@ npm install -g peaks-cli
|
|
|
13
13
|
|
|
14
14
|
安装后,Peaks 会把内置的 8 个 `peaks-*` 技能注册到 Claude Code,会话里直接通过技能名调用即可。
|
|
15
15
|
|
|
16
|
+
## 本地开发(从源码跑 CLI)
|
|
17
|
+
|
|
18
|
+
仓库自带 `peaks` CLI 源码。开发模式用 `tsx` 直接跑 `src/cli/index.ts`,所以**首次克隆后 `node_modules/` 里不会有 `chalk` / `ora` / `terminal-kit` 等运行时依赖**——直接 `tsx src/cli/index.ts` 会报 `ERR_MODULE_NOT_FOUND: chalk`。先执行一次 `pnpm install` 把依赖补齐,再验证:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm install
|
|
22
|
+
pnpm exec tsx src/cli/index.ts --version # 应打印 1.2.9
|
|
23
|
+
pnpm exec tsx src/cli/index.ts <cmd> # 与全局 `peaks <cmd>` 行为一致
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
热重载开发循环可用 `pnpm dev:watch`。
|
|
27
|
+
|
|
16
28
|
## 5 分钟上手
|
|
17
29
|
|
|
18
30
|
在 Claude Code 对话里,**直接对 Claude 说「用 X 技能做 Y」** 即可,技能会接管剩下的所有流程:
|
package/bin/peaks.js
CHANGED
|
File without changes
|
|
@@ -123,7 +123,7 @@ export function registerProjectCommands(program, io) {
|
|
|
123
123
|
.command('memories')
|
|
124
124
|
.description('Read durable project memories (decisions, conventions, modules, rules) from .peaks/memory for LLM consumption')
|
|
125
125
|
.requiredOption('--project <path>', 'target project root')
|
|
126
|
-
.option('--kind <kind>', 'filter by kind: project, rule, decision, reference, feedback, convention, module')).action((options) => {
|
|
126
|
+
.option('--kind <kind>', 'filter by kind: project, rule, decision, reference, feedback, convention, module, lesson')).action((options) => {
|
|
127
127
|
try {
|
|
128
128
|
const result = readProjectMemories(options.project);
|
|
129
129
|
if (options.kind) {
|
|
@@ -5,6 +5,7 @@ import { checkTypeSanity } from '../../services/scan/type-sanity-service.js';
|
|
|
5
5
|
import { getAcceptanceCoverage, isAcceptanceCoverageError } from '../../services/scan/acceptance-coverage-service.js';
|
|
6
6
|
import { getDiffVsScope, isDiffScopeError } from '../../services/scan/diff-scope-service.js';
|
|
7
7
|
import { scanFileSize, DEFAULT_FILE_SIZE_THRESHOLD } from '../../services/scan/file-size-scan.js';
|
|
8
|
+
import { scanLibraries } from '../../services/scan/libraries-service.js';
|
|
8
9
|
import { isRequestType, VALID_REQUEST_TYPES } from '../../services/artifacts/artifact-prerequisites.js';
|
|
9
10
|
import { fail, ok } from '../../shared/result.js';
|
|
10
11
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
@@ -221,4 +222,25 @@ export function registerScanCommands(program, io) {
|
|
|
221
222
|
process.exitCode = 1;
|
|
222
223
|
}
|
|
223
224
|
});
|
|
225
|
+
addJsonOption(scan
|
|
226
|
+
.command('libraries')
|
|
227
|
+
.description('Enumerate every dependency + devDependency + peerDependency + optionalDependency in package.json with parsed major version (read-only). Output goes to ## Library versions in rd/project-scan.md.')
|
|
228
|
+
.requiredOption('--project <path>', 'target project root')).action(async (options) => {
|
|
229
|
+
try {
|
|
230
|
+
const report = await scanLibraries({ projectRoot: options.project });
|
|
231
|
+
const nextActions = [];
|
|
232
|
+
if (report.libraries.length === 0) {
|
|
233
|
+
nextActions.push('No dependencies found — verify package.json exists and is valid JSON.');
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
nextActions.push('Paste the report under `## Library versions` in .peaks/<sid>/rd/project-scan.md.');
|
|
237
|
+
nextActions.push('peaks-rd preflight will cross-check diff imports against schemas/library-breaking-changes.data.json.');
|
|
238
|
+
}
|
|
239
|
+
printResult(io, ok('scan.libraries', report, [], nextActions), options.json);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
printResult(io, fail('scan.libraries', 'SCAN_LIBRARIES_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is readable']), options.json);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
224
246
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ProjectMemoryKind = 'project' | 'rule' | 'decision' | 'reference' | 'feedback' | 'convention' | 'module';
|
|
1
|
+
export type ProjectMemoryKind = 'project' | 'rule' | 'decision' | 'reference' | 'feedback' | 'convention' | 'module' | 'lesson';
|
|
2
2
|
export type ExtractedProjectMemory = {
|
|
3
3
|
title: string;
|
|
4
4
|
kind: ProjectMemoryKind;
|
|
@@ -3,13 +3,20 @@ import { dirname, basename, isAbsolute, join, relative, resolve } from 'node:pat
|
|
|
3
3
|
import { isInsidePath, isWindowsAbsolutePath, normalizePath, resolveInputPath, stablePath, stableRealPath } from '../../shared/path-utils.js';
|
|
4
4
|
import { containsSensitiveConfigValue, isSensitiveConfigPath } from '../config/config-service.js';
|
|
5
5
|
// Hot kinds: full body kept in index for always-available context
|
|
6
|
-
const HOT_KINDS = new Set(['feedback', 'decision', 'rule', 'convention', 'module']);
|
|
6
|
+
const HOT_KINDS = new Set(['feedback', 'decision', 'rule', 'convention', 'module', 'lesson']);
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Internal helpers (kept from original, sorted by dependency order)
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
const START_MARKER = '<!-- peaks-memory:start -->';
|
|
11
11
|
const END_MARKER = '<!-- peaks-memory:end -->';
|
|
12
|
-
const VALID_MEMORY_KINDS = new Set(['project', 'rule', 'decision', 'reference', 'feedback', 'convention', 'module']);
|
|
12
|
+
const VALID_MEMORY_KINDS = new Set(['project', 'rule', 'decision', 'reference', 'feedback', 'convention', 'module', 'lesson']);
|
|
13
|
+
// Length bounds for index entry descriptions. The numbers were chosen when
|
|
14
|
+
// summarizeMemoryBody was first introduced; locking them in as named
|
|
15
|
+
// constants is a doc-as-code move so the truncation rule is no longer
|
|
16
|
+
// "magic". Bump MAX_DESCRIPTION_LENGTH deliberately if downstream UIs grow.
|
|
17
|
+
const MIN_BODY_SENTENCE_LENGTH = 20; // skip fragments shorter than this when picking a leading sentence
|
|
18
|
+
const MAX_DESCRIPTION_LENGTH = 120; // hard cap on description length in the memory index entry
|
|
19
|
+
const ELLIPSIS_RESERVE = 3; // length of the trailing "..." when truncating with an ellipsis
|
|
13
20
|
function normalizeRoot(path) {
|
|
14
21
|
return resolveInputPath(path);
|
|
15
22
|
}
|
|
@@ -214,15 +221,15 @@ function summarizeMemoryBody(body) {
|
|
|
214
221
|
.replace(/^\s*[-*+]\s+/gm, '')
|
|
215
222
|
.replace(/\n+/g, ' ')
|
|
216
223
|
.trim();
|
|
217
|
-
const sentences = cleaned.split(/(?<=[.!?])\s+/).filter((s) => s.length >
|
|
224
|
+
const sentences = cleaned.split(/(?<=[.!?])\s+/).filter((s) => s.length > MIN_BODY_SENTENCE_LENGTH && !/^\[.+\]$/.test(s));
|
|
218
225
|
if (sentences.length === 0) {
|
|
219
|
-
return cleaned.slice(0,
|
|
226
|
+
return cleaned.slice(0, MAX_DESCRIPTION_LENGTH) || 'Project memory';
|
|
220
227
|
}
|
|
221
228
|
const first = sentences[0];
|
|
222
|
-
if (first.length <=
|
|
229
|
+
if (first.length <= MAX_DESCRIPTION_LENGTH) {
|
|
223
230
|
return first;
|
|
224
231
|
}
|
|
225
|
-
return first.slice(0,
|
|
232
|
+
return first.slice(0, MAX_DESCRIPTION_LENGTH - ELLIPSIS_RESERVE) + '...';
|
|
226
233
|
}
|
|
227
234
|
// ---------------------------------------------------------------------------
|
|
228
235
|
// Session memory extraction (new extract path)
|
|
@@ -296,7 +303,7 @@ function readStoredMemoryNames(memoryDir) {
|
|
|
296
303
|
function generateMemoryIndexFile(projectRoot, memoryDir, indexPath) {
|
|
297
304
|
const memories = readProjectMemories(projectRoot);
|
|
298
305
|
const hot = {
|
|
299
|
-
feedback: [], decision: [], rule: [], convention: [], module: []
|
|
306
|
+
feedback: [], decision: [], rule: [], convention: [], module: [], lesson: []
|
|
300
307
|
};
|
|
301
308
|
const warm = {
|
|
302
309
|
project: [], reference: []
|
|
@@ -350,29 +357,49 @@ function readExistingIndex(indexPath) {
|
|
|
350
357
|
return null;
|
|
351
358
|
}
|
|
352
359
|
}
|
|
360
|
+
// Decide whether readMemoryIndex should rebuild the on-disk index.json.
|
|
361
|
+
// The rule is: rebuild iff index.json is missing OR any memory.md has an
|
|
362
|
+
// mtime strictly greater than index.json's mtime. Any statSync failure
|
|
363
|
+
// falls back to "rebuild" — a safe default that matches the prior
|
|
364
|
+
// always-rebuild behaviour and avoids serving a stale index from a
|
|
365
|
+
// partially-corrupt dir.
|
|
366
|
+
function shouldRegenerateIndex(indexPath, memoryFiles) {
|
|
367
|
+
let indexMtimeMs = 0;
|
|
368
|
+
try {
|
|
369
|
+
indexMtimeMs = statSync(indexPath).mtimeMs;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return true; // no index → must regenerate
|
|
373
|
+
}
|
|
374
|
+
for (const memoryPath of memoryFiles) {
|
|
375
|
+
try {
|
|
376
|
+
const memoryMtimeMs = statSync(memoryPath).mtimeMs;
|
|
377
|
+
if (memoryMtimeMs > indexMtimeMs)
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return true; // unreadable file → safe default is regenerate
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
353
386
|
export function readMemoryIndex(projectRoot) {
|
|
354
387
|
const normalizedRoot = normalizeRoot(projectRoot);
|
|
355
388
|
const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
|
|
356
389
|
const indexPath = join(memoryDir, 'index.json');
|
|
357
|
-
// Read-side bootstrap: if the memory dir is missing entirely, build
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
390
|
+
// Read-side bootstrap: if the memory dir is missing entirely, build it and
|
|
391
|
+
// return whatever index is on disk (likely null on a fresh project). We
|
|
392
|
+
// deliberately do NOT pre-write an empty index here: the mtime-based
|
|
393
|
+
// regeneration guard below is the sole authority on whether index.json
|
|
394
|
+
// gets materialised, and pre-writing an empty index would race the guard
|
|
395
|
+
// (giving it a current-time mtime that defeats "memory older than index"
|
|
396
|
+
// detection on the first read).
|
|
362
397
|
if (!existsSync(memoryDir)) {
|
|
363
398
|
ensureMemoryBootstrap(normalizedRoot);
|
|
364
399
|
return readExistingIndex(indexPath);
|
|
365
400
|
}
|
|
366
|
-
if (!existsSync(indexPath)) {
|
|
367
|
-
try {
|
|
368
|
-
writeFileSync(indexPath, renderEmptyIndex(), { mode: 0o644 });
|
|
369
|
-
}
|
|
370
|
-
catch {
|
|
371
|
-
// fall through — readExistingIndex will return null
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
401
|
const files = listMarkdownFiles(memoryDir);
|
|
375
|
-
if (files.length > 0) {
|
|
402
|
+
if (files.length > 0 && shouldRegenerateIndex(indexPath, files)) {
|
|
376
403
|
try {
|
|
377
404
|
generateMemoryIndexFile(normalizedRoot, memoryDir, indexPath);
|
|
378
405
|
}
|
|
@@ -659,7 +686,8 @@ function emptyByKind() {
|
|
|
659
686
|
reference: [],
|
|
660
687
|
feedback: [],
|
|
661
688
|
convention: [],
|
|
662
|
-
module: []
|
|
689
|
+
module: [],
|
|
690
|
+
lesson: []
|
|
663
691
|
};
|
|
664
692
|
}
|
|
665
693
|
function emptyIndex() {
|
|
@@ -676,7 +704,8 @@ function emptyIndex() {
|
|
|
676
704
|
decision: [],
|
|
677
705
|
rule: [],
|
|
678
706
|
convention: [],
|
|
679
|
-
module: []
|
|
707
|
+
module: [],
|
|
708
|
+
lesson: []
|
|
680
709
|
},
|
|
681
710
|
warm: {
|
|
682
711
|
project: [],
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LibraryReport } from './libraries-types.js';
|
|
2
|
+
export type ScanLibrariesOptions = {
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Parse the major version from a semver-ish spec.
|
|
7
|
+
*
|
|
8
|
+
* Handles the common shapes:
|
|
9
|
+
* "^5.18.0" → 5
|
|
10
|
+
* "~1.2.3" → 1
|
|
11
|
+
* "1.2.3" → 1
|
|
12
|
+
* ">=1.0.0" → 1
|
|
13
|
+
* "5" → 5
|
|
14
|
+
* "5.x" → 5
|
|
15
|
+
*
|
|
16
|
+
* Returns null for non-semver specs that the LLM should not assume a
|
|
17
|
+
* major for:
|
|
18
|
+
* "workspace:*" → null
|
|
19
|
+
* "file:../..." → null
|
|
20
|
+
* "git+https..." → null
|
|
21
|
+
* "npm:@scope/x@1" → 1 (alias spec, we extract what we can)
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseMajorVersion(spec: string): number | null;
|
|
24
|
+
export declare function scanLibraries(options: ScanLibrariesOptions): Promise<LibraryReport>;
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, sep } from 'node:path';
|
|
3
|
+
import { isDirectory, pathExists, readText } from '../../shared/fs.js';
|
|
4
|
+
const SCOPES = [
|
|
5
|
+
'dependencies',
|
|
6
|
+
'devDependencies',
|
|
7
|
+
'peerDependencies',
|
|
8
|
+
'optionalDependencies'
|
|
9
|
+
];
|
|
10
|
+
const SCOPE_ORDER = {
|
|
11
|
+
dependencies: 0,
|
|
12
|
+
devDependencies: 1,
|
|
13
|
+
peerDependencies: 2,
|
|
14
|
+
optionalDependencies: 3
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Parse the major version from a semver-ish spec.
|
|
18
|
+
*
|
|
19
|
+
* Handles the common shapes:
|
|
20
|
+
* "^5.18.0" → 5
|
|
21
|
+
* "~1.2.3" → 1
|
|
22
|
+
* "1.2.3" → 1
|
|
23
|
+
* ">=1.0.0" → 1
|
|
24
|
+
* "5" → 5
|
|
25
|
+
* "5.x" → 5
|
|
26
|
+
*
|
|
27
|
+
* Returns null for non-semver specs that the LLM should not assume a
|
|
28
|
+
* major for:
|
|
29
|
+
* "workspace:*" → null
|
|
30
|
+
* "file:../..." → null
|
|
31
|
+
* "git+https..." → null
|
|
32
|
+
* "npm:@scope/x@1" → 1 (alias spec, we extract what we can)
|
|
33
|
+
*/
|
|
34
|
+
export function parseMajorVersion(spec) {
|
|
35
|
+
// Strip npm alias prefix "npm:" and take whatever comes after the LAST "@"
|
|
36
|
+
// (since alias specs are "npm:<name>@<version>")
|
|
37
|
+
let versionPart = spec;
|
|
38
|
+
if (versionPart.startsWith('npm:')) {
|
|
39
|
+
const atIdx = versionPart.lastIndexOf('@');
|
|
40
|
+
versionPart = atIdx >= 0 ? versionPart.slice(atIdx + 1) : versionPart.slice(4);
|
|
41
|
+
}
|
|
42
|
+
// Strip semver range operators (caret, tilde, comparison ops, exact)
|
|
43
|
+
versionPart = versionPart.replace(/^[\^~>=<]+\s*/, '').trim();
|
|
44
|
+
// Take the first numeric component
|
|
45
|
+
const match = /^(\d+)/.exec(versionPart);
|
|
46
|
+
if (!match)
|
|
47
|
+
return null;
|
|
48
|
+
const n = Number(match[1]);
|
|
49
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
50
|
+
}
|
|
51
|
+
async function readPackageJson(packageJsonPath) {
|
|
52
|
+
if (!(await pathExists(packageJsonPath))) {
|
|
53
|
+
return { exists: false, record: null };
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const raw = await readText(packageJsonPath);
|
|
57
|
+
const record = JSON.parse(raw);
|
|
58
|
+
return { exists: true, record };
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return { exists: true, record: null, error: error.message };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Hand-rolled YAML parser for `pnpm-workspace.yaml`.
|
|
66
|
+
*
|
|
67
|
+
* Only consumes the top-level `packages:` list. Ignores all other top-level
|
|
68
|
+
* keys (`allowBuilds:`, `catalog:`, etc.) and indented blocks. Returns
|
|
69
|
+
* `null` when the file is present but does not contain a `packages:`
|
|
70
|
+
* list — callers should fall through to the next detection source.
|
|
71
|
+
*
|
|
72
|
+
* Supports the common shapes:
|
|
73
|
+
* packages:
|
|
74
|
+
* - 'packages/*'
|
|
75
|
+
* - "apps/web"
|
|
76
|
+
* - tools
|
|
77
|
+
* - packages/hermes-agent/*
|
|
78
|
+
*/
|
|
79
|
+
function parsePnpmWorkspaceYaml(content) {
|
|
80
|
+
const lines = content.split(/\r?\n/);
|
|
81
|
+
let inPackagesList = false;
|
|
82
|
+
let packagesIndent = -1;
|
|
83
|
+
const result = [];
|
|
84
|
+
for (const rawLine of lines) {
|
|
85
|
+
// Strip comments (anything after # not inside quotes — close enough for our use)
|
|
86
|
+
const commentIdx = rawLine.indexOf('#');
|
|
87
|
+
const line = (commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine).replace(/\s+$/, '');
|
|
88
|
+
if (line.length === 0)
|
|
89
|
+
continue;
|
|
90
|
+
if (!inPackagesList) {
|
|
91
|
+
// Looking for the top-level `packages:` key.
|
|
92
|
+
const match = /^packages:\s*$/.exec(line);
|
|
93
|
+
if (match) {
|
|
94
|
+
inPackagesList = true;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Inside the packages list. Items must be more indented than the key.
|
|
99
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
100
|
+
if (packagesIndent < 0) {
|
|
101
|
+
// First non-empty line after the key — this sets the indent.
|
|
102
|
+
if (indent === 0) {
|
|
103
|
+
// `packages:` was the last meaningful line; an unindented line
|
|
104
|
+
// means the list is empty or the format is unsupported.
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
packagesIndent = indent;
|
|
108
|
+
}
|
|
109
|
+
else if (indent < packagesIndent) {
|
|
110
|
+
// Outdented — the list ended.
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
else if (indent > packagesIndent) {
|
|
114
|
+
// Nested list (e.g. `packages: [{ ... }]`) — unsupported; stop.
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
// Expect `- <value>` or `- '<value>'` or `- "<value>"`.
|
|
118
|
+
const itemMatch = /^\s*-\s+(['"]?)(.+?)\1\s*$/.exec(line);
|
|
119
|
+
if (itemMatch && itemMatch[2] !== undefined) {
|
|
120
|
+
result.push(itemMatch[2]);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Not a list item; the block ended.
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
function extractNpmWorkspaces(value) {
|
|
130
|
+
if (Array.isArray(value))
|
|
131
|
+
return value;
|
|
132
|
+
if (value && typeof value === 'object' && Array.isArray(value.packages)) {
|
|
133
|
+
return value.packages;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Discover workspace `package.json` paths from the supported monorepo
|
|
139
|
+
* manifest sources. Returns the list of absolute paths plus which
|
|
140
|
+
* detection source won. When no source is present, returns an empty
|
|
141
|
+
* list with `source: null` (caller should fall through to single-package
|
|
142
|
+
* behavior).
|
|
143
|
+
*/
|
|
144
|
+
async function discoverWorkspacePackageJsons(projectRoot, warnings) {
|
|
145
|
+
// 1. pnpm-workspace.yaml
|
|
146
|
+
const pnpmWsPath = join(projectRoot, 'pnpm-workspace.yaml');
|
|
147
|
+
if (await pathExists(pnpmWsPath)) {
|
|
148
|
+
try {
|
|
149
|
+
const yaml = await readText(pnpmWsPath);
|
|
150
|
+
const globs = parsePnpmWorkspaceYaml(yaml);
|
|
151
|
+
if (globs && globs.length > 0) {
|
|
152
|
+
const paths = await expandWorkspaceGlobs(projectRoot, globs, warnings);
|
|
153
|
+
return { paths, source: 'pnpm-workspace' };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
warnings.push(`pnpm-workspace.yaml present but unreadable: ${error.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 2. package.json `workspaces` field (npm or yarn classic).
|
|
161
|
+
const rootPkgPath = join(projectRoot, 'package.json');
|
|
162
|
+
if (await pathExists(rootPkgPath)) {
|
|
163
|
+
const { record } = await readPackageJson(rootPkgPath);
|
|
164
|
+
if (record) {
|
|
165
|
+
const globs = extractNpmWorkspaces(record.workspaces);
|
|
166
|
+
if (globs && globs.length > 0) {
|
|
167
|
+
const paths = await expandWorkspaceGlobs(projectRoot, globs, warnings);
|
|
168
|
+
return { paths, source: 'package-json-workspaces' };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// 3. lerna.json `packages` field.
|
|
173
|
+
const lernaPath = join(projectRoot, 'lerna.json');
|
|
174
|
+
if (await pathExists(lernaPath)) {
|
|
175
|
+
try {
|
|
176
|
+
const lernaRaw = await readText(lernaPath);
|
|
177
|
+
const lerna = JSON.parse(lernaRaw);
|
|
178
|
+
if (Array.isArray(lerna.packages) && lerna.packages.every((p) => typeof p === 'string')) {
|
|
179
|
+
const paths = await expandWorkspaceGlobs(projectRoot, lerna.packages, warnings);
|
|
180
|
+
return { paths, source: 'lerna' };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
warnings.push(`lerna.json present but unreadable: ${error.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { paths: [], source: null };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Resolve a list of workspace globs against the project tree.
|
|
191
|
+
*
|
|
192
|
+
* Supports shapes with up to two segments of `*` wildcards:
|
|
193
|
+
* - `packages/*`
|
|
194
|
+
* - `packages/hermes-agent/*`
|
|
195
|
+
* - `apps/web` (literal, no wildcard)
|
|
196
|
+
*
|
|
197
|
+
* Deeper patterns (more than one `*` segment) are silently skipped and a
|
|
198
|
+
* warning is emitted. This is by design (no new dependency, narrow scope).
|
|
199
|
+
*/
|
|
200
|
+
async function expandWorkspaceGlobs(projectRoot, globs, warnings) {
|
|
201
|
+
const found = new Set();
|
|
202
|
+
for (const glob of globs) {
|
|
203
|
+
const segments = glob.split('/').filter((s) => s.length > 0);
|
|
204
|
+
const wildcardCount = segments.filter((s) => s === '*').length;
|
|
205
|
+
if (wildcardCount > 1) {
|
|
206
|
+
warnings.push(`workspace glob "${glob}" has more than one wildcard; skipped (unsupported).`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (segments.length === 0)
|
|
210
|
+
continue;
|
|
211
|
+
if (wildcardCount === 0) {
|
|
212
|
+
// Literal path — treat the last segment as a directory containing package.json.
|
|
213
|
+
const literalDir = join(projectRoot, ...segments);
|
|
214
|
+
if (await isDirectory(literalDir)) {
|
|
215
|
+
const candidate = join(literalDir, 'package.json');
|
|
216
|
+
if (await pathExists(candidate)) {
|
|
217
|
+
found.add(candidate);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// Exactly one wildcard — it must be the LAST segment.
|
|
223
|
+
const wildcardIdx = segments.indexOf('*');
|
|
224
|
+
if (wildcardIdx !== segments.length - 1) {
|
|
225
|
+
warnings.push(`workspace glob "${glob}" has non-trailing wildcard; skipped.`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const parentSegments = segments.slice(0, wildcardIdx);
|
|
229
|
+
const parentDir = join(projectRoot, ...parentSegments);
|
|
230
|
+
if (!(await isDirectory(parentDir)))
|
|
231
|
+
continue;
|
|
232
|
+
let entries;
|
|
233
|
+
try {
|
|
234
|
+
entries = await readdir(parentDir, { withFileTypes: true });
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
warnings.push(`workspace glob "${glob}": could not read ${parentDir}: ${error.message}`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (!entry.isDirectory())
|
|
242
|
+
continue;
|
|
243
|
+
const childPkg = join(parentDir, entry.name, 'package.json');
|
|
244
|
+
if (await pathExists(childPkg)) {
|
|
245
|
+
found.add(childPkg);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return Array.from(found).sort();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Parse a single `package.json` record into `LibraryEntry` rows.
|
|
253
|
+
* Returns the entries and the per-scope tallies for that one package.
|
|
254
|
+
*/
|
|
255
|
+
function extractEntriesFromPackageJson(record) {
|
|
256
|
+
const byScope = {
|
|
257
|
+
dependencies: 0,
|
|
258
|
+
devDependencies: 0,
|
|
259
|
+
peerDependencies: 0,
|
|
260
|
+
optionalDependencies: 0
|
|
261
|
+
};
|
|
262
|
+
const entries = [];
|
|
263
|
+
for (const scope of SCOPES) {
|
|
264
|
+
const deps = record[scope];
|
|
265
|
+
if (!deps)
|
|
266
|
+
continue;
|
|
267
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
268
|
+
entries.push({
|
|
269
|
+
name,
|
|
270
|
+
version,
|
|
271
|
+
major: parseMajorVersion(version),
|
|
272
|
+
scope,
|
|
273
|
+
ecosystem: 'npm'
|
|
274
|
+
});
|
|
275
|
+
byScope[scope] += 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { entries, byScope };
|
|
279
|
+
}
|
|
280
|
+
export async function scanLibraries(options) {
|
|
281
|
+
const { projectRoot } = options;
|
|
282
|
+
const warnings = [];
|
|
283
|
+
const libraries = [];
|
|
284
|
+
const byScope = {
|
|
285
|
+
dependencies: 0,
|
|
286
|
+
devDependencies: 0,
|
|
287
|
+
peerDependencies: 0,
|
|
288
|
+
optionalDependencies: 0
|
|
289
|
+
};
|
|
290
|
+
const workspaces = [];
|
|
291
|
+
// 1. Discover monorepo workspaces (if any). Discovery is independent of
|
|
292
|
+
// whether the root `package.json` exists; we still attempt it.
|
|
293
|
+
const { paths: workspacePkgPaths, source } = await discoverWorkspacePackageJsons(projectRoot, warnings);
|
|
294
|
+
const isMonorepo = source !== null && workspacePkgPaths.length > 0;
|
|
295
|
+
if (isMonorepo) {
|
|
296
|
+
// Monorepo mode: scan the root + every discovered workspace package.json.
|
|
297
|
+
const rootPkgPath = join(projectRoot, 'package.json');
|
|
298
|
+
const allPaths = [];
|
|
299
|
+
if (await pathExists(rootPkgPath)) {
|
|
300
|
+
allPaths.push(rootPkgPath);
|
|
301
|
+
}
|
|
302
|
+
// Dedupe: the root might also be matched by a glob (rare, but possible).
|
|
303
|
+
for (const p of workspacePkgPaths) {
|
|
304
|
+
if (!allPaths.includes(p))
|
|
305
|
+
allPaths.push(p);
|
|
306
|
+
}
|
|
307
|
+
// Recursive descent: for each discovered workspace dir, also pick up
|
|
308
|
+
// any nested `package.json` one level deeper. This matches the pnpm
|
|
309
|
+
// convention where a workspace package (e.g. `hermes-agent`) can be
|
|
310
|
+
// a container for sub-workspaces (e.g. `hermes-agent/ui-tui`). The
|
|
311
|
+
// outer pnpm-workspace.yaml often only declares the top-level glob,
|
|
312
|
+
// so a literal-glob-only scan would miss the nested packages.
|
|
313
|
+
const expanded = new Set(allPaths);
|
|
314
|
+
for (const pkgPath of allPaths) {
|
|
315
|
+
const dir = pkgPath.replace(/[\\/]package\.json$/, '');
|
|
316
|
+
if (!(await isDirectory(dir)))
|
|
317
|
+
continue;
|
|
318
|
+
let entries;
|
|
319
|
+
try {
|
|
320
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
if (!entry.isDirectory())
|
|
327
|
+
continue;
|
|
328
|
+
const nestedPkg = join(dir, entry.name, 'package.json');
|
|
329
|
+
if (await pathExists(nestedPkg)) {
|
|
330
|
+
expanded.add(nestedPkg);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
allPaths.length = 0;
|
|
335
|
+
allPaths.push(...Array.from(expanded).sort());
|
|
336
|
+
for (const pkgPath of allPaths) {
|
|
337
|
+
const { exists, record, error } = await readPackageJson(pkgPath);
|
|
338
|
+
if (!exists)
|
|
339
|
+
continue;
|
|
340
|
+
if (record === null) {
|
|
341
|
+
warnings.push(`${normalizePathForDisplay(pkgPath)} is not valid JSON: ${error ?? 'unknown parse error'}`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const { entries, byScope: pkgByScope } = extractEntriesFromPackageJson(record);
|
|
345
|
+
libraries.push(...entries);
|
|
346
|
+
for (const scope of SCOPES) {
|
|
347
|
+
byScope[scope] += pkgByScope[scope];
|
|
348
|
+
}
|
|
349
|
+
// For per-workspace `workspaces[]`, only list non-root paths; the
|
|
350
|
+
// root is implicit and lives at `projectRoot`.
|
|
351
|
+
const isRoot = pkgPath === rootPkgPath;
|
|
352
|
+
if (!isRoot) {
|
|
353
|
+
const entry = {
|
|
354
|
+
path: pkgPath,
|
|
355
|
+
count: entries.length
|
|
356
|
+
};
|
|
357
|
+
if (record.name !== undefined)
|
|
358
|
+
entry.name = record.name;
|
|
359
|
+
if (record.version !== undefined)
|
|
360
|
+
entry.version = record.version;
|
|
361
|
+
workspaces.push(entry);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// Single-package mode (today's behavior). Preserved byte-for-byte
|
|
367
|
+
// apart from the new additive `workspaces: []` field.
|
|
368
|
+
const { exists, record, error } = await readPackageJson(join(projectRoot, 'package.json'));
|
|
369
|
+
if (!exists) {
|
|
370
|
+
warnings.push('package.json not found; nothing to scan.');
|
|
371
|
+
return {
|
|
372
|
+
projectRoot,
|
|
373
|
+
libraries,
|
|
374
|
+
totalCount: 0,
|
|
375
|
+
byScope,
|
|
376
|
+
workspaces: [],
|
|
377
|
+
scannedAt: new Date().toISOString(),
|
|
378
|
+
warnings
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (record === null) {
|
|
382
|
+
warnings.push(`package.json is not valid JSON: ${error ?? 'unknown parse error'}`);
|
|
383
|
+
return {
|
|
384
|
+
projectRoot,
|
|
385
|
+
libraries,
|
|
386
|
+
totalCount: 0,
|
|
387
|
+
byScope,
|
|
388
|
+
workspaces: [],
|
|
389
|
+
scannedAt: new Date().toISOString(),
|
|
390
|
+
warnings
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const { entries, byScope: pkgByScope } = extractEntriesFromPackageJson(record);
|
|
394
|
+
libraries.push(...entries);
|
|
395
|
+
for (const scope of SCOPES) {
|
|
396
|
+
byScope[scope] += pkgByScope[scope];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Sort: by name (alphabetical), then by scope order (deps first, then dev, peer, optional)
|
|
400
|
+
libraries.sort((a, b) => {
|
|
401
|
+
if (a.name !== b.name)
|
|
402
|
+
return a.name.localeCompare(b.name);
|
|
403
|
+
return SCOPE_ORDER[a.scope] - SCOPE_ORDER[b.scope];
|
|
404
|
+
});
|
|
405
|
+
return {
|
|
406
|
+
projectRoot,
|
|
407
|
+
libraries,
|
|
408
|
+
totalCount: libraries.length,
|
|
409
|
+
byScope,
|
|
410
|
+
workspaces,
|
|
411
|
+
scannedAt: new Date().toISOString(),
|
|
412
|
+
warnings
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function normalizePathForDisplay(p) {
|
|
416
|
+
// Surface posix-style separators in warnings for consistency with the
|
|
417
|
+
// existing 'package.json is not valid JSON' message shape.
|
|
418
|
+
return p.split(sep).join('/');
|
|
419
|
+
}
|