vouchington-tooling 0.20.0 → 0.21.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 +14 -9
- package/dist/dependency-license-policy/audit-directory.d.mts +28 -0
- package/dist/dependency-license-policy/audit-directory.mjs +115 -0
- package/dist/dependency-license-policy/audit-signals.d.mts +12 -0
- package/dist/dependency-license-policy/audit-signals.mjs +44 -0
- package/dist/dependency-license-policy/audit-store.d.mts +12 -0
- package/dist/dependency-license-policy/audit-store.mjs +32 -0
- package/dist/dependency-license-policy/collect.d.mts +3 -1
- package/dist/dependency-license-policy/collect.mjs +16 -21
- package/dist/dependency-license-policy/execute-pnpm.d.mts +10 -0
- package/dist/dependency-license-policy/execute-pnpm.mjs +83 -0
- package/dist/dependency-license-policy/types.d.mts +11 -9
- package/dist/dependency-license-policy/workspace.d.mts +3 -1
- package/dist/dependency-license-policy/workspace.mjs +12 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -326,19 +326,24 @@ policy out of this package.
|
|
|
326
326
|
dependency declared by a non-fixture package manifest. Assert dependency membership or placement,
|
|
327
327
|
or derive a configuration or documentation package spec from that manifest instead.
|
|
328
328
|
|
|
329
|
-
`dependency-license-policy` keeps legal policy in the consumer. `collectPnpmLicenseReport`
|
|
330
|
-
an isolated, script-free temporary workspace
|
|
331
|
-
every `os`, `cpu`, and `libc` selector represented in the lockfile, drops every
|
|
332
|
-
from the audit copy of the lockfile, and validates the JSON report. Dropping
|
|
333
|
-
`pnpm fetch` from skipping optional packages that exclude the running Node.js;
|
|
334
|
-
ignores `force`, so their licenses would otherwise report as Unknown.
|
|
329
|
+
`dependency-license-policy` keeps legal policy in the consumer. `collectPnpmLicenseReport` returns
|
|
330
|
+
a promise. It creates an isolated, script-free temporary workspace, expands pnpm's supported
|
|
331
|
+
architectures to every `os`, `cpu`, and `libc` selector represented in the lockfile, drops every
|
|
332
|
+
`engines` constraint from the audit copy of the lockfile, and validates the JSON report. Dropping
|
|
333
|
+
`engines` keeps `pnpm fetch` from skipping optional packages that exclude the running Node.js;
|
|
334
|
+
pnpm 12's `fetch` ignores `force`, so their licenses would otherwise report as Unknown.
|
|
335
|
+
Packages are fetched into a dedicated owner-only store under the pnpm cache
|
|
336
|
+
(`dependency-license-audit-store`) so a later audit reuses content-addressed packages instead of
|
|
337
|
+
downloading every platform again, without writing those packages into the developer store.
|
|
335
338
|
Pass explicit denied SPDX IDs and prefixes, exact aliases, and justified allowlist scopes to
|
|
336
339
|
`evaluatePnpmLicenseReport`. Unknown, malformed, and custom SPDX references fail closed. Allowlist
|
|
337
340
|
scopes are either intentionally global or an exact package-name set; the library returns structured
|
|
338
341
|
violations and does not format CI-provider diagnostics.
|
|
339
|
-
When present, the repository `.npmrc` is copied into the owner-private temporary
|
|
340
|
-
can authenticate to the same registries
|
|
341
|
-
|
|
342
|
+
When present, the repository `.npmrc` is copied into the owner-private temporary workspace so pnpm
|
|
343
|
+
can authenticate to the same registries. The workspace is removed when the audit finishes, when the
|
|
344
|
+
process receives SIGINT, SIGTERM, or SIGHUP, and on the next audit if the previous process died
|
|
345
|
+
first, including SIGKILL. Each workspace records its owner's PID so a later audit can delete
|
|
346
|
+
directories whose owner is gone.
|
|
342
347
|
|
|
343
348
|
`session-friction` is an opt-in capture and reporting library. Callers supply the session id,
|
|
344
349
|
absolute log directory, host-independent observation, and journal loader; it does not inspect host
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const LICENSE_AUDIT_DIRECTORY_PREFIX = "dependency-license-audit-";
|
|
2
|
+
export interface LicenseAuditReclaimOptions {
|
|
3
|
+
readonly graceMs?: number;
|
|
4
|
+
readonly isOwnerAlive?: (pid: number, directoryMtimeMs: number) => boolean;
|
|
5
|
+
readonly now?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface LicenseAuditWorkspaceOptions extends LicenseAuditReclaimOptions {
|
|
8
|
+
readonly directory?: string;
|
|
9
|
+
readonly pid?: number;
|
|
10
|
+
}
|
|
11
|
+
interface OwnerAliveDependencies {
|
|
12
|
+
readonly isProcessAlive?: (pid: number) => boolean;
|
|
13
|
+
readonly readProcessStartMs?: (pid: number) => number | undefined;
|
|
14
|
+
}
|
|
15
|
+
/** Parses `ps -o lstart=` output. Exported for tests of unparseable dates. */
|
|
16
|
+
export declare function parseProcessStart(stdout: string): number | undefined;
|
|
17
|
+
export declare function readProcessStartMs(pid: number): number | undefined;
|
|
18
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* A directory still belongs to its creator when that PID is alive and the process started
|
|
21
|
+
* before the directory. A recycled PID belongs to a newer process and is not the owner.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isAuditOwnerAlive(pid: number, directoryMtimeMs: number, dependencies?: OwnerAliveDependencies): boolean;
|
|
24
|
+
export declare function writeAuditPid(directory: string, pid?: number): void;
|
|
25
|
+
export declare function removeAuditDirectory(directory: string): void;
|
|
26
|
+
/** Deletes leftover audit directories whose owner process is gone, including after SIGKILL. */
|
|
27
|
+
export declare function reclaimStaleLicenseAuditDirectories(directory: string, options?: LicenseAuditReclaimOptions): void;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export const LICENSE_AUDIT_DIRECTORY_PREFIX = 'dependency-license-audit-';
|
|
5
|
+
const LICENSE_AUDIT_PID_FILE = 'audit.pid';
|
|
6
|
+
const STALE_AUDIT_GRACE_MS = 60_000;
|
|
7
|
+
const OWNER_START_SLACK_MS = 1_000;
|
|
8
|
+
const MAX_PID = 2_147_483_647;
|
|
9
|
+
function hasCode(error, code) {
|
|
10
|
+
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === code);
|
|
11
|
+
}
|
|
12
|
+
/** Parses `ps -o lstart=` output. Exported for tests of unparseable dates. */
|
|
13
|
+
export function parseProcessStart(stdout) {
|
|
14
|
+
const parsed = Date.parse(stdout.trim());
|
|
15
|
+
if (Number.isNaN(parsed))
|
|
16
|
+
return undefined;
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
export function readProcessStartMs(pid) {
|
|
20
|
+
const result = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' });
|
|
21
|
+
if (result.status !== 0)
|
|
22
|
+
return undefined;
|
|
23
|
+
return parseProcessStart(result.stdout);
|
|
24
|
+
}
|
|
25
|
+
export function isProcessAlive(pid) {
|
|
26
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > MAX_PID)
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
return !hasCode(error, 'ESRCH');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A directory still belongs to its creator when that PID is alive and the process started
|
|
38
|
+
* before the directory. A recycled PID belongs to a newer process and is not the owner.
|
|
39
|
+
*/
|
|
40
|
+
export function isAuditOwnerAlive(pid, directoryMtimeMs, dependencies = {}) {
|
|
41
|
+
if (!(dependencies.isProcessAlive ?? isProcessAlive)(pid))
|
|
42
|
+
return false;
|
|
43
|
+
const started = (dependencies.readProcessStartMs ?? readProcessStartMs)(pid);
|
|
44
|
+
if (started === undefined)
|
|
45
|
+
return true;
|
|
46
|
+
return started <= directoryMtimeMs + OWNER_START_SLACK_MS;
|
|
47
|
+
}
|
|
48
|
+
export function writeAuditPid(directory, pid = process.pid) {
|
|
49
|
+
const path = join(directory, LICENSE_AUDIT_PID_FILE);
|
|
50
|
+
writeFileSync(path, `${String(pid)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
51
|
+
chmodSync(path, 0o600);
|
|
52
|
+
}
|
|
53
|
+
function readAuditPid(directory) {
|
|
54
|
+
try {
|
|
55
|
+
const text = readFileSync(join(directory, LICENSE_AUDIT_PID_FILE), 'utf8').trim();
|
|
56
|
+
if (!/^[1-9]\d*$/.test(text))
|
|
57
|
+
return undefined;
|
|
58
|
+
const pid = Number.parseInt(text, 10);
|
|
59
|
+
return Number.isSafeInteger(pid) && pid <= MAX_PID ? pid : undefined;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (hasCode(error, 'ENOENT'))
|
|
63
|
+
return undefined;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function removeAuditDirectory(directory) {
|
|
68
|
+
const stat = lstatSync(directory, { throwIfNoEntry: false });
|
|
69
|
+
if (!stat)
|
|
70
|
+
return;
|
|
71
|
+
if (stat.isSymbolicLink()) {
|
|
72
|
+
unlinkSync(directory);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!stat.isDirectory())
|
|
76
|
+
return;
|
|
77
|
+
rmSync(directory, { force: true, recursive: true });
|
|
78
|
+
}
|
|
79
|
+
function reclaimNames(directory) {
|
|
80
|
+
try {
|
|
81
|
+
return readdirSync(directory, { encoding: 'utf8' });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (hasCode(error, 'ENOENT'))
|
|
85
|
+
return [];
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function shouldRemoveAuditDirectory(directory, mtimeMs, now, graceMs, isOwnerAlive) {
|
|
90
|
+
const pid = readAuditPid(directory);
|
|
91
|
+
if (pid === undefined)
|
|
92
|
+
return now - mtimeMs >= graceMs;
|
|
93
|
+
return !isOwnerAlive(pid, mtimeMs);
|
|
94
|
+
}
|
|
95
|
+
/** Deletes leftover audit directories whose owner process is gone, including after SIGKILL. */
|
|
96
|
+
export function reclaimStaleLicenseAuditDirectories(directory, options = {}) {
|
|
97
|
+
const now = options.now ?? Date.now();
|
|
98
|
+
const graceMs = options.graceMs ?? STALE_AUDIT_GRACE_MS;
|
|
99
|
+
const isOwnerAlive = options.isOwnerAlive ?? isAuditOwnerAlive;
|
|
100
|
+
for (const name of reclaimNames(directory)) {
|
|
101
|
+
if (!name.startsWith(LICENSE_AUDIT_DIRECTORY_PREFIX))
|
|
102
|
+
continue;
|
|
103
|
+
const path = join(directory, name);
|
|
104
|
+
const stat = lstatSync(path);
|
|
105
|
+
if (stat.isSymbolicLink()) {
|
|
106
|
+
unlinkSync(path);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!stat.isDirectory())
|
|
110
|
+
continue;
|
|
111
|
+
if (shouldRemoveAuditDirectory(path, stat.mtimeMs, now, graceMs, isOwnerAlive)) {
|
|
112
|
+
rmSync(path, { force: true, recursive: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type SignalListener = () => void;
|
|
2
|
+
export interface AuditSignalDependencies {
|
|
3
|
+
readonly raiseSignal?: (signal: NodeJS.Signals) => void;
|
|
4
|
+
readonly subscribe?: (signal: NodeJS.Signals, listener: SignalListener) => void;
|
|
5
|
+
readonly unsubscribe?: (signal: NodeJS.Signals, listener: SignalListener) => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Runs an audit while SIGINT, SIGTERM, and SIGHUP abort it, remove its workspace, and are raised
|
|
9
|
+
* again so the process still exits from that signal.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withAuditSignalCleanup<T>(cleanup: () => void, run: (signal: AbortSignal) => Promise<T>, dependencies?: AuditSignalDependencies): Promise<T>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const AUDIT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
2
|
+
/**
|
|
3
|
+
* Runs an audit while SIGINT, SIGTERM, and SIGHUP abort it, remove its workspace, and are raised
|
|
4
|
+
* again so the process still exits from that signal.
|
|
5
|
+
*/
|
|
6
|
+
export async function withAuditSignalCleanup(cleanup, run, dependencies = {}) {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
let raised;
|
|
9
|
+
const handlers = AUDIT_SIGNALS.map((signal) => {
|
|
10
|
+
const listener = () => {
|
|
11
|
+
if (raised !== undefined)
|
|
12
|
+
return;
|
|
13
|
+
raised = signal;
|
|
14
|
+
controller.abort(signal);
|
|
15
|
+
};
|
|
16
|
+
if (dependencies.subscribe)
|
|
17
|
+
dependencies.subscribe(signal, listener);
|
|
18
|
+
else
|
|
19
|
+
process.on(signal, listener);
|
|
20
|
+
return { listener, signal };
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
return await run(controller.signal);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
for (const { listener, signal } of handlers) {
|
|
27
|
+
if (dependencies.unsubscribe)
|
|
28
|
+
dependencies.unsubscribe(signal, listener);
|
|
29
|
+
else
|
|
30
|
+
process.removeListener(signal, listener);
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
cleanup();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
if (raised !== undefined) {
|
|
37
|
+
if (dependencies.raiseSignal)
|
|
38
|
+
dependencies.raiseSignal(raised);
|
|
39
|
+
else
|
|
40
|
+
process.kill(process.pid, raised);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface DirectoryIdentity {
|
|
2
|
+
readonly isDirectory: () => boolean;
|
|
3
|
+
readonly isSymbolicLink: () => boolean;
|
|
4
|
+
readonly mode: number;
|
|
5
|
+
readonly uid: number;
|
|
6
|
+
}
|
|
7
|
+
/** pnpm's cache directory. The audit store stays beside it so it is not the developer store. */
|
|
8
|
+
export declare function pnpmCacheDirectory(env?: NodeJS.ProcessEnv, home?: string, hostPlatform?: NodeJS.Platform): string;
|
|
9
|
+
export declare function licenseAuditStoreDirectory(env?: NodeJS.ProcessEnv, home?: string, hostPlatform?: NodeJS.Platform): string;
|
|
10
|
+
export declare function assertOwnedDirectory(stat: DirectoryIdentity, uid: number | undefined, directory: string): void;
|
|
11
|
+
/** Creates the dedicated content-addressed store used by repeat license audits. */
|
|
12
|
+
export declare function ensurePrivateLicenseAuditStore(directory: string): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const STORE_DIRECTORY_NAME = 'dependency-license-audit-store';
|
|
5
|
+
/** pnpm's cache directory. The audit store stays beside it so it is not the developer store. */
|
|
6
|
+
export function pnpmCacheDirectory(env = process.env, home = homedir(), hostPlatform = process.platform) {
|
|
7
|
+
if (hostPlatform === 'darwin')
|
|
8
|
+
return join(home, 'Library', 'Caches', 'pnpm');
|
|
9
|
+
if (hostPlatform === 'win32') {
|
|
10
|
+
return join(env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'pnpm-cache');
|
|
11
|
+
}
|
|
12
|
+
return join(env.XDG_CACHE_HOME ?? join(home, '.cache'), 'pnpm');
|
|
13
|
+
}
|
|
14
|
+
export function licenseAuditStoreDirectory(env = process.env, home = homedir(), hostPlatform = process.platform) {
|
|
15
|
+
return join(pnpmCacheDirectory(env, home, hostPlatform), STORE_DIRECTORY_NAME);
|
|
16
|
+
}
|
|
17
|
+
export function assertOwnedDirectory(stat, uid, directory) {
|
|
18
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
19
|
+
throw new Error(`dependency license audit store is not a real directory: ${directory}`);
|
|
20
|
+
}
|
|
21
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
22
|
+
throw new Error(`dependency license audit store is not owned by the current user: ${directory}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Creates the dedicated content-addressed store used by repeat license audits. */
|
|
26
|
+
export function ensurePrivateLicenseAuditStore(directory) {
|
|
27
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
28
|
+
const stat = lstatSync(directory);
|
|
29
|
+
assertOwnedDirectory(stat, process.getuid?.(), directory);
|
|
30
|
+
if ((stat.mode & 0o077) !== 0)
|
|
31
|
+
chmodSync(directory, 0o700);
|
|
32
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { PnpmExecutor, PnpmLicenseReport } from './types.mts';
|
|
2
2
|
import { type PnpmLicenseAuditWorkspace } from './workspace.mts';
|
|
3
3
|
export interface CollectPnpmLicenseReportOptions {
|
|
4
|
+
readonly ensureStore?: (directory: string) => void;
|
|
4
5
|
readonly execute?: PnpmExecutor;
|
|
5
6
|
readonly prepareWorkspace?: (repoRoot: string, lockfileSource: string, workspaceSource: string) => PnpmLicenseAuditWorkspace;
|
|
6
7
|
readonly readFile?: (path: string, encoding: 'utf8') => string;
|
|
8
|
+
readonly storeDir?: string;
|
|
7
9
|
}
|
|
8
10
|
/** Collects licenses for every platform represented in a pnpm lockfile. */
|
|
9
|
-
export declare function collectPnpmLicenseReport(repoRoot: string, options?: CollectPnpmLicenseReportOptions): PnpmLicenseReport
|
|
11
|
+
export declare function collectPnpmLicenseReport(repoRoot: string, options?: CollectPnpmLicenseReportOptions): Promise<PnpmLicenseReport>;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
1
|
import { readFileSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
3
|
+
import { withAuditSignalCleanup } from './audit-signals.mjs';
|
|
4
|
+
import { ensurePrivateLicenseAuditStore, licenseAuditStoreDirectory } from './audit-store.mjs';
|
|
5
|
+
import { executePnpm } from './execute-pnpm.mjs';
|
|
4
6
|
import { parsePnpmLicenseReport } from './report.mjs';
|
|
5
7
|
import { preparePnpmLicenseAuditWorkspace } from './workspace.mjs';
|
|
6
8
|
const DEFAULT_OPTIONS = {
|
|
7
|
-
|
|
9
|
+
ensureStore: ensurePrivateLicenseAuditStore,
|
|
10
|
+
execute: executePnpm,
|
|
8
11
|
prepareWorkspace: preparePnpmLicenseAuditWorkspace,
|
|
9
12
|
readFile: readFileSync,
|
|
10
13
|
};
|
|
@@ -19,22 +22,17 @@ function assertCommandSucceeded(label, result) {
|
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
/** Collects licenses for every platform represented in a pnpm lockfile. */
|
|
22
|
-
export function collectPnpmLicenseReport(repoRoot, options = {}) {
|
|
23
|
-
const { execute, prepareWorkspace, readFile } = { ...DEFAULT_OPTIONS, ...options };
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const storeConfig = `--config.store-dir=${
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
encoding: 'utf8',
|
|
32
|
-
});
|
|
25
|
+
export async function collectPnpmLicenseReport(repoRoot, options = {}) {
|
|
26
|
+
const { ensureStore, execute, prepareWorkspace, readFile } = { ...DEFAULT_OPTIONS, ...options };
|
|
27
|
+
const auditWorkspace = prepareWorkspace(repoRoot, readFile(join(repoRoot, 'pnpm-lock.yaml'), 'utf8'), readFile(join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'));
|
|
28
|
+
const storeDir = options.storeDir ?? licenseAuditStoreDirectory();
|
|
29
|
+
return withAuditSignalCleanup(auditWorkspace.cleanup, async (signal) => {
|
|
30
|
+
ensureStore(storeDir);
|
|
31
|
+
const storeConfig = `--config.store-dir=${storeDir}`;
|
|
32
|
+
const commandOptions = { cwd: auditWorkspace.cwd, encoding: 'utf8', signal };
|
|
33
|
+
const fetchResult = await execute('pnpm', [storeConfig, 'fetch', '--ignore-scripts'], commandOptions);
|
|
33
34
|
assertCommandSucceeded('pnpm fetch', fetchResult);
|
|
34
|
-
const result = execute('pnpm', [storeConfig, 'licenses', 'list', '--json'],
|
|
35
|
-
cwd: auditWorkspace.cwd,
|
|
36
|
-
encoding: 'utf8',
|
|
37
|
-
});
|
|
35
|
+
const result = await execute('pnpm', [storeConfig, 'licenses', 'list', '--json'], commandOptions);
|
|
38
36
|
assertCommandSucceeded('pnpm licenses list --json', result);
|
|
39
37
|
try {
|
|
40
38
|
return parsePnpmLicenseReport(JSON.parse(result.stdout));
|
|
@@ -44,8 +42,5 @@ export function collectPnpmLicenseReport(repoRoot, options = {}) {
|
|
|
44
42
|
cause: error,
|
|
45
43
|
});
|
|
46
44
|
}
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
auditWorkspace.cleanup();
|
|
50
|
-
}
|
|
45
|
+
});
|
|
51
46
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Readable } from 'node:stream';
|
|
2
|
+
import type { PnpmExecutor } from './types.mts';
|
|
3
|
+
export declare function signalFromAbort(signal: AbortSignal): NodeJS.Signals;
|
|
4
|
+
export declare function stopChildProcess(child: {
|
|
5
|
+
kill: (signal?: NodeJS.Signals) => boolean;
|
|
6
|
+
pid?: number | undefined;
|
|
7
|
+
}, signal: NodeJS.Signals): void;
|
|
8
|
+
export declare function collectChildStream(stream: Readable | null, chunks: string[], onOverflow: () => void): void;
|
|
9
|
+
/** Runs pnpm in its own process group so a signal can reach it and its children. */
|
|
10
|
+
export declare const executePnpm: PnpmExecutor;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
3
|
+
export function signalFromAbort(signal) {
|
|
4
|
+
if (signal.reason === 'SIGINT' || signal.reason === 'SIGTERM' || signal.reason === 'SIGHUP') {
|
|
5
|
+
return signal.reason;
|
|
6
|
+
}
|
|
7
|
+
return 'SIGTERM';
|
|
8
|
+
}
|
|
9
|
+
export function stopChildProcess(child, signal) {
|
|
10
|
+
if (child.pid === undefined) {
|
|
11
|
+
child.kill(signal);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
process.kill(-child.pid, signal);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
child.kill(signal);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function collectChildStream(stream, chunks, onOverflow) {
|
|
22
|
+
if (stream === null)
|
|
23
|
+
throw new Error('license audit child is missing a stdio pipe');
|
|
24
|
+
stream.setEncoding('utf8');
|
|
25
|
+
stream.on('data', (chunk) => {
|
|
26
|
+
chunks.push(chunk);
|
|
27
|
+
if (Buffer.byteLength(chunks.join('')) > MAX_BUFFER_BYTES)
|
|
28
|
+
onOverflow();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function finishOnce(settle) {
|
|
32
|
+
let settled = false;
|
|
33
|
+
return (result) => {
|
|
34
|
+
if (settled)
|
|
35
|
+
return;
|
|
36
|
+
settled = true;
|
|
37
|
+
settle(result);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Runs pnpm in its own process group so a signal can reach it and its children. */
|
|
41
|
+
export const executePnpm = (command, args, options) => new Promise((resolve) => {
|
|
42
|
+
const child = spawn(command, args, {
|
|
43
|
+
cwd: options.cwd,
|
|
44
|
+
detached: true,
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
const stdout = [];
|
|
48
|
+
const stderr = [];
|
|
49
|
+
let overflow = false;
|
|
50
|
+
const stop = () => {
|
|
51
|
+
stopChildProcess(child, overflow ? 'SIGKILL' : signalFromAbort(options.signal));
|
|
52
|
+
};
|
|
53
|
+
if (options.signal.aborted)
|
|
54
|
+
stop();
|
|
55
|
+
else
|
|
56
|
+
options.signal.addEventListener('abort', stop, { once: true });
|
|
57
|
+
const markOverflow = () => {
|
|
58
|
+
overflow = true;
|
|
59
|
+
stop();
|
|
60
|
+
};
|
|
61
|
+
collectChildStream(child.stdout, stdout, markOverflow);
|
|
62
|
+
collectChildStream(child.stderr, stderr, markOverflow);
|
|
63
|
+
const finish = finishOnce((result) => {
|
|
64
|
+
options.signal.removeEventListener('abort', stop);
|
|
65
|
+
resolve(result);
|
|
66
|
+
});
|
|
67
|
+
child.once('error', (error) => {
|
|
68
|
+
finish({ error, status: null, stderr: stderr.join(''), stdout: stdout.join('') });
|
|
69
|
+
});
|
|
70
|
+
child.once('close', (status) => {
|
|
71
|
+
const result = { status, stderr: stderr.join(''), stdout: stdout.join('') };
|
|
72
|
+
if (!overflow) {
|
|
73
|
+
finish(result);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
finish({
|
|
77
|
+
...result,
|
|
78
|
+
error: Object.assign(new Error('child output maxBuffer exceeded'), {
|
|
79
|
+
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -4,15 +4,17 @@ export interface PnpmLicenseReportEntry {
|
|
|
4
4
|
}
|
|
5
5
|
/** Shape emitted by `pnpm licenses list --json`. */
|
|
6
6
|
export type PnpmLicenseReport = Record<string, PnpmLicenseReportEntry[]>;
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
export interface PnpmCommandResult {
|
|
8
|
+
readonly error?: Error;
|
|
9
|
+
readonly status: number | null;
|
|
10
|
+
readonly stderr: string;
|
|
11
|
+
readonly stdout: string;
|
|
12
|
+
}
|
|
13
|
+
export type PnpmExecutor = (command: string, args: readonly string[], options: {
|
|
14
|
+
readonly cwd: string;
|
|
15
|
+
readonly encoding: 'utf8';
|
|
16
|
+
readonly signal: AbortSignal;
|
|
17
|
+
}) => PnpmCommandResult | Promise<PnpmCommandResult>;
|
|
16
18
|
export interface DependencyLicenseAllowlistEntry {
|
|
17
19
|
/** Exact SPDX atom allowed by this entry. */
|
|
18
20
|
readonly licenseId: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LicenseAuditWorkspaceOptions } from './audit-directory.mts';
|
|
1
2
|
export interface PnpmLicenseAuditWorkspace {
|
|
2
3
|
readonly cleanup: () => void;
|
|
3
4
|
readonly cwd: string;
|
|
@@ -19,4 +20,5 @@ export declare function renderPnpmLicenseAuditFiles(lockfileSource: string, work
|
|
|
19
20
|
lockfile: string;
|
|
20
21
|
workspace: string;
|
|
21
22
|
}): PnpmLicenseAuditFiles;
|
|
22
|
-
export declare function
|
|
23
|
+
export declare function licenseAuditParentDirectory(directory?: string): string;
|
|
24
|
+
export declare function preparePnpmLicenseAuditWorkspace(repoRoot: string, lockfileSource: string, workspaceSource: string, options?: LicenseAuditWorkspaceOptions): PnpmLicenseAuditWorkspace;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdtempSync,
|
|
1
|
+
import { copyFileSync, existsSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isMap, parseDocument, stringify as stringifyYaml } from 'yaml';
|
|
5
|
+
import { LICENSE_AUDIT_DIRECTORY_PREFIX, reclaimStaleLicenseAuditDirectories, removeAuditDirectory, writeAuditPid, } from './audit-directory.mjs';
|
|
5
6
|
const PLATFORM_KEYS = ['os', 'cpu', 'libc'];
|
|
6
7
|
function parseYamlDocument(source, path) {
|
|
7
8
|
const document = parseDocument(source);
|
|
@@ -71,9 +72,15 @@ export function renderPnpmLicenseAuditFiles(lockfileSource, workspaceSource, pat
|
|
|
71
72
|
workspace: stringifyYaml({ ...workspace, packages: [], supportedArchitectures }),
|
|
72
73
|
};
|
|
73
74
|
}
|
|
74
|
-
export function
|
|
75
|
-
|
|
75
|
+
export function licenseAuditParentDirectory(directory) {
|
|
76
|
+
return directory ?? tmpdir();
|
|
77
|
+
}
|
|
78
|
+
export function preparePnpmLicenseAuditWorkspace(repoRoot, lockfileSource, workspaceSource, options = {}) {
|
|
79
|
+
const directory = licenseAuditParentDirectory(options.directory);
|
|
80
|
+
reclaimStaleLicenseAuditDirectories(directory, options);
|
|
81
|
+
const auditRoot = mkdtempSync(join(directory, LICENSE_AUDIT_DIRECTORY_PREFIX));
|
|
76
82
|
try {
|
|
83
|
+
writeAuditPid(auditRoot, options.pid);
|
|
77
84
|
copyFileSync(join(repoRoot, 'package.json'), join(auditRoot, 'package.json'));
|
|
78
85
|
const npmrc = join(repoRoot, '.npmrc');
|
|
79
86
|
if (existsSync(npmrc))
|
|
@@ -86,11 +93,11 @@ export function preparePnpmLicenseAuditWorkspace(repoRoot, lockfileSource, works
|
|
|
86
93
|
writeFileSync(join(auditRoot, 'pnpm-workspace.yaml'), files.workspace, 'utf8');
|
|
87
94
|
}
|
|
88
95
|
catch (error) {
|
|
89
|
-
|
|
96
|
+
removeAuditDirectory(auditRoot);
|
|
90
97
|
throw error;
|
|
91
98
|
}
|
|
92
99
|
return {
|
|
93
100
|
cwd: auditRoot,
|
|
94
|
-
cleanup: () =>
|
|
101
|
+
cleanup: () => removeAuditDirectory(auditRoot),
|
|
95
102
|
};
|
|
96
103
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|