angular-compat-matrix 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +406 -2
- package/dist/app.js +91 -0
- package/dist/build.js +167 -0
- package/dist/cleanup.js +117 -0
- package/dist/cli.js +262 -0
- package/dist/exec.js +166 -0
- package/dist/install.js +140 -0
- package/dist/library.js +200 -0
- package/dist/model.js +13 -0
- package/dist/partial.js +90 -0
- package/dist/peers.js +79 -0
- package/dist/progress.js +65 -0
- package/dist/registry.js +135 -0
- package/dist/report.js +280 -0
- package/dist/run.js +280 -0
- package/dist/scripts.js +73 -0
- package/dist/smoke.js +526 -0
- package/dist/tests.js +105 -0
- package/dist/tools.js +152 -0
- package/dist/versions.js +138 -0
- package/package.json +49 -4
package/dist/exec.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const ANSI = /\u001b\[[0-9;?]*[A-Za-z]|\u001b\][^\u0007]*\u0007/g;
|
|
5
|
+
export function stripAnsi(text) {
|
|
6
|
+
return text.replace(ANSI, '');
|
|
7
|
+
}
|
|
8
|
+
/** Environment every child gets: no prompts, no analytics, no colour codes to strip later. */
|
|
9
|
+
export function quietEnv(extra = {}) {
|
|
10
|
+
return {
|
|
11
|
+
...process.env,
|
|
12
|
+
NG_CLI_ANALYTICS: 'false',
|
|
13
|
+
NG_DISABLE_VERSION_CHECK: '1',
|
|
14
|
+
NO_UPDATE_NOTIFIER: '1',
|
|
15
|
+
NPM_CONFIG_UPDATE_NOTIFIER: 'false',
|
|
16
|
+
NPM_CONFIG_FUND: 'false',
|
|
17
|
+
NPM_CONFIG_AUDIT: 'false',
|
|
18
|
+
FORCE_COLOR: '0',
|
|
19
|
+
...extra,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Every child still running, so an interrupt can stop them all. */
|
|
23
|
+
const running = new Set();
|
|
24
|
+
/** Stops every running child and everything it started (npm, then the build it runs). */
|
|
25
|
+
export function killAllChildren() {
|
|
26
|
+
for (const child of running)
|
|
27
|
+
killTree(child.pid);
|
|
28
|
+
running.clear();
|
|
29
|
+
}
|
|
30
|
+
let verboseEnabled = false;
|
|
31
|
+
export function setVerbose(enabled) {
|
|
32
|
+
verboseEnabled = enabled;
|
|
33
|
+
}
|
|
34
|
+
export function isVerbose() {
|
|
35
|
+
return verboseEnabled;
|
|
36
|
+
}
|
|
37
|
+
export function exec(command, args, options) {
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
if (verboseEnabled) {
|
|
40
|
+
const fullCmd = options.shell ? [command, ...args].join(' ') : `${command} ${args.join(' ')}`;
|
|
41
|
+
process.stderr.write(`[ng-matrix] > ${fullCmd}\n`);
|
|
42
|
+
}
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
// On Linux and macOS each child leads its own process group, so killing the group stops
|
|
45
|
+
// its children too. Windows has taskkill /T for that.
|
|
46
|
+
const detached = process.platform !== 'win32';
|
|
47
|
+
const child = options.shell
|
|
48
|
+
? spawn([command, ...args].join(' '), { cwd: options.cwd, env: options.env ?? quietEnv(), shell: true, detached })
|
|
49
|
+
: spawn(command, args, { cwd: options.cwd, env: options.env ?? quietEnv(), detached });
|
|
50
|
+
running.add(child);
|
|
51
|
+
let output = '';
|
|
52
|
+
let timedOut = false;
|
|
53
|
+
const append = (chunk) => (output += chunk.toString('utf8'));
|
|
54
|
+
child.stdout?.on('data', append);
|
|
55
|
+
child.stderr?.on('data', append);
|
|
56
|
+
const timer = options.timeoutMs
|
|
57
|
+
? setTimeout(() => {
|
|
58
|
+
timedOut = true;
|
|
59
|
+
killTree(child.pid);
|
|
60
|
+
}, options.timeoutMs)
|
|
61
|
+
: undefined;
|
|
62
|
+
const finish = (code) => {
|
|
63
|
+
running.delete(child);
|
|
64
|
+
if (timer)
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
resolve({ code, output: stripAnsi(output), durationMs: Date.now() - started, timedOut });
|
|
67
|
+
};
|
|
68
|
+
child.on('error', (error) => {
|
|
69
|
+
output += `\n${error.message}`;
|
|
70
|
+
finish(127);
|
|
71
|
+
});
|
|
72
|
+
child.on('close', (code) => finish(code ?? 1));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function killTree(pid) {
|
|
76
|
+
if (!pid)
|
|
77
|
+
return;
|
|
78
|
+
if (process.platform === 'win32') {
|
|
79
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F']);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
try {
|
|
83
|
+
process.kill(-pid, 'SIGKILL');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 'SIGKILL');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// already gone
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* npm's own CLI script, so npm runs as `node npm-cli.js` without a shell
|
|
97
|
+
* (spawning `npm.cmd` on Windows needs one, and then every argument needs quoting).
|
|
98
|
+
*/
|
|
99
|
+
export function npmCli() {
|
|
100
|
+
const candidates = [
|
|
101
|
+
process.env.npm_execpath,
|
|
102
|
+
join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
103
|
+
join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
104
|
+
];
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
if (candidate && /npm-cli\.js$/.test(candidate) && existsSync(candidate))
|
|
107
|
+
return [process.execPath, candidate];
|
|
108
|
+
}
|
|
109
|
+
throw new Error("Couldn't find npm next to this Node. ng-matrix needs the npm that ships with Node.");
|
|
110
|
+
}
|
|
111
|
+
export function npm(args, options) {
|
|
112
|
+
const [node, cli] = npmCli();
|
|
113
|
+
return exec(node, [cli, ...args], options);
|
|
114
|
+
}
|
|
115
|
+
/** The first lines that look like errors, with a little context, for reports. */
|
|
116
|
+
export function errorLines(output, max = 12) {
|
|
117
|
+
const lines = output.split(/\r?\n/).map((line) => line.trimEnd());
|
|
118
|
+
// Errors first; warnings (Angular prints NG8113 "not used" ones, say) only when there's nothing else.
|
|
119
|
+
const errors = /(\[ERROR\]|\berror TS\d+|\bERR_PNPM_|npm error|^\s*Error:|✘|✕|^\s*X |\berror NG\d{4}\b|ERESOLVE|unmet peer|invalid: )/i;
|
|
120
|
+
const anything = /(\[WARNING\]|\bNG\d{4}\b|\bwarn)/i;
|
|
121
|
+
for (const marker of [errors, anything]) {
|
|
122
|
+
const picked = [];
|
|
123
|
+
for (let i = 0; i < lines.length && picked.length < max; i++) {
|
|
124
|
+
if (!marker.test(lines[i]))
|
|
125
|
+
continue;
|
|
126
|
+
for (let j = i; j < Math.min(lines.length, i + 6) && picked.length < max; j++) {
|
|
127
|
+
const line = lines[j];
|
|
128
|
+
if (j > i && (errors.test(line) || anything.test(line)) && !marker.test(line))
|
|
129
|
+
break;
|
|
130
|
+
if (line.trim() && !picked.includes(line))
|
|
131
|
+
picked.push(line);
|
|
132
|
+
}
|
|
133
|
+
i += 5;
|
|
134
|
+
}
|
|
135
|
+
if (picked.length)
|
|
136
|
+
return picked;
|
|
137
|
+
}
|
|
138
|
+
return lines.filter((line) => line.trim()).slice(-max);
|
|
139
|
+
}
|
|
140
|
+
/** How long any one step (ng new, an install, a build, the tests) may take: --step-timeout. */
|
|
141
|
+
let stepTimeoutMs = 20 * 60_000;
|
|
142
|
+
export function setStepTimeout(ms) {
|
|
143
|
+
stepTimeoutMs = ms;
|
|
144
|
+
}
|
|
145
|
+
export function stepTimeout() {
|
|
146
|
+
return stepTimeoutMs;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Why a step failed when it's the machine's fault, not the package's: it timed out, the disk is
|
|
150
|
+
* full, or the network dropped. Such a version is reported as not checked (exit 2), not as failing.
|
|
151
|
+
*/
|
|
152
|
+
export function environmentProblem(result) {
|
|
153
|
+
if (result.timedOut)
|
|
154
|
+
return `timed out after ${formatMinutes(stepTimeoutMs)} (raise the limit with --step-timeout <minutes>)`;
|
|
155
|
+
if (/\bENOSPC\b|no space left on device|not enough space on the disk/i.test(result.output)) {
|
|
156
|
+
return 'the disk is full (ENOSPC). Free some space, or point TMPDIR/TEMP and NG_MATRIX_CACHE somewhere with room';
|
|
157
|
+
}
|
|
158
|
+
const network = /^.*\b(EAI_AGAIN|ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ERR_SOCKET_TIMEOUT|ERR_PNPM_META_FETCH_FAIL|socket hang up)\b.*$/im.exec(result.output);
|
|
159
|
+
if (network)
|
|
160
|
+
return `a network error: ${network[0].trim().slice(0, 200)}`;
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
function formatMinutes(ms) {
|
|
164
|
+
const minutes = ms / 60_000;
|
|
165
|
+
return minutes >= 1 ? `${Number(minutes.toFixed(1))} min` : `${Math.round(ms / 1000)} s`;
|
|
166
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { copyFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import semver from 'semver';
|
|
4
|
+
import { readJson, writeJson } from "./app.js";
|
|
5
|
+
import { environmentProblem, errorLines, exec, npm, quietEnv, stepTimeout } from "./exec.js";
|
|
6
|
+
import { npmInstall, pnpmBuildSettings, skippedByNpm, skippedByPnpm } from "./scripts.js";
|
|
7
|
+
import { ensureTool, PNPM_VERSION, pnpmEntry } from "./tools.js";
|
|
8
|
+
export function addDependencies(appDir, deps, extra = {}) {
|
|
9
|
+
const path = join(appDir, 'package.json');
|
|
10
|
+
const manifest = readJson(path);
|
|
11
|
+
manifest.dependencies = { ...(manifest.dependencies ?? {}), ...deps };
|
|
12
|
+
Object.assign(manifest, extra);
|
|
13
|
+
writeJson(path, manifest);
|
|
14
|
+
}
|
|
15
|
+
export async function installLibrary(request) {
|
|
16
|
+
const tgz = basename(request.tarball);
|
|
17
|
+
copyFileSync(request.tarball, join(request.appDir, tgz));
|
|
18
|
+
const deps = { ...request.plan.installs, [request.libraryName]: `file:${tgz}` };
|
|
19
|
+
return request.pm === 'npm' ? installWithNpm(request, deps) : installWithPnpm(request, deps);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* npm 11 fails on any peer conflict and can't ignore just one. So: install without peer
|
|
23
|
+
* resolution, with the peers added explicitly, then let `npm ls` report every invalid peer.
|
|
24
|
+
*/
|
|
25
|
+
async function installWithNpm(request, deps) {
|
|
26
|
+
addDependencies(request.appDir, deps);
|
|
27
|
+
const args = ['install', '--legacy-peer-deps', '--no-audit', '--no-fund'];
|
|
28
|
+
if (request.offline)
|
|
29
|
+
args.push('--offline');
|
|
30
|
+
const result = await npmInstall(args, { cwd: request.appDir, timeoutMs: stepTimeout() }, request.allowScripts);
|
|
31
|
+
const skippedScripts = request.allowScripts ? [] : skippedByNpm(request.appDir);
|
|
32
|
+
if (result.code !== 0) {
|
|
33
|
+
return {
|
|
34
|
+
pass: false,
|
|
35
|
+
message: 'npm install failed.',
|
|
36
|
+
errorLines: errorLines(result.output),
|
|
37
|
+
fullOutput: result.output,
|
|
38
|
+
ignored: [],
|
|
39
|
+
environment: environmentProblem(result),
|
|
40
|
+
skippedScripts,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const ls = await npm(['ls', '--all'], { cwd: request.appDir });
|
|
44
|
+
const problems = parseNpmLs(ls.output);
|
|
45
|
+
return { ...judgeProblems(request, problems), skippedScripts };
|
|
46
|
+
}
|
|
47
|
+
/** Reads `npm ls --all` text output: `@angular/core@21.2.25 deduped invalid: "^22.0.0" from node_modules/@angular/cdk`. */
|
|
48
|
+
export function parseNpmLs(output) {
|
|
49
|
+
const problems = [];
|
|
50
|
+
for (const raw of output.split(/\r?\n/)) {
|
|
51
|
+
const line = raw.replace(/^[\s|`+\\-]+/, '').trim();
|
|
52
|
+
const invalid = /^(@?[^@\s]+)@(\S+)(?: deduped)? invalid: (.+)$/.exec(line);
|
|
53
|
+
if (invalid) {
|
|
54
|
+
for (const want of invalid[3].matchAll(/"([^"]+)" from (\S+?)(?:,|$)/g)) {
|
|
55
|
+
problems.push({ name: invalid[1], installed: invalid[2], wanted: want[1], by: want[2].replace(/^.*node_modules\//, ''), line });
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const unmet = /UNMET (?:OPTIONAL )?(?:PEER )?DEPENDENCY (@?[^@\s]+)@(\S+)/.exec(line);
|
|
60
|
+
if (unmet && !/OPTIONAL/.test(line))
|
|
61
|
+
problems.push({ name: unmet[1], wanted: unmet[2], line });
|
|
62
|
+
}
|
|
63
|
+
return problems;
|
|
64
|
+
}
|
|
65
|
+
function judgeProblems(request, problems) {
|
|
66
|
+
// Only conflicts about the package under test and the peers installed for it count;
|
|
67
|
+
// anything else in the tree is the generated app's own business.
|
|
68
|
+
const ours = new Set([request.libraryName, ...Object.keys(request.plan.installs)]);
|
|
69
|
+
const ourPeers = new Set([...Object.keys(request.plan.installs), ...Object.keys(request.plan.checked)]);
|
|
70
|
+
const relevant = problems.filter((p) => (p.by ? ours.has(p.by) : ourPeers.has(p.name)));
|
|
71
|
+
const outside = request.plan.claimed === false;
|
|
72
|
+
const ignored = relevant.filter((p) => outside && p.name.startsWith('@angular/') && p.by === request.libraryName);
|
|
73
|
+
const failing = relevant.filter((p) => !ignored.includes(p));
|
|
74
|
+
const describe = (p) => `${p.by ?? '?'} wants ${p.name}@"${p.wanted}"${p.installed ? `, found ${p.installed}` : ', missing'}`;
|
|
75
|
+
if (failing.length) {
|
|
76
|
+
return {
|
|
77
|
+
pass: false,
|
|
78
|
+
message: `Peer conflict: ${describe(failing[0])}${failing.length > 1 ? ` (+${failing.length - 1} more)` : ''}.`,
|
|
79
|
+
errorLines: failing.map((p) => p.line),
|
|
80
|
+
ignored: ignored.map(describe),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
pass: true,
|
|
85
|
+
message: ignored.length ? `Installed; ignored the expected conflict: ${ignored.map(describe).join('; ')}.` : 'Installed.',
|
|
86
|
+
errorLines: [],
|
|
87
|
+
ignored: ignored.map(describe),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* pnpm only warns about peers by default, so strictPeerDependencies is on, with allowedVersions
|
|
92
|
+
* for the one expected conflict. strictDepBuilds is off, so a package whose install script isn't
|
|
93
|
+
* allowed is skipped rather than failing the install; only the toolchain's builds are allowed.
|
|
94
|
+
*/
|
|
95
|
+
async function installWithPnpm(request, deps) {
|
|
96
|
+
addDependencies(request.appDir, deps, { packageManager: `pnpm@${PNPM_VERSION}` });
|
|
97
|
+
const allowed = {};
|
|
98
|
+
if (request.plan.claimed === false) {
|
|
99
|
+
for (const miss of request.plan.misses) {
|
|
100
|
+
const major = semver.valid(miss.version) ? String(semver.major(miss.version)) : String(request.angularMajor);
|
|
101
|
+
allowed[`${request.libraryName}>${miss.name}`] = major;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
writeFileSync(join(request.appDir, 'pnpm-workspace.yaml'), pnpmWorkspaceYaml(allowed, request.allowScripts));
|
|
105
|
+
const pnpm = await ensureTool(request.cacheDir, 'pnpm', PNPM_VERSION, request.offline);
|
|
106
|
+
const args = [pnpmEntry(pnpm), 'install', '--no-frozen-lockfile'];
|
|
107
|
+
if (request.offline)
|
|
108
|
+
args.push('--offline');
|
|
109
|
+
const result = await exec(process.execPath, args, { cwd: request.appDir, env: quietEnv(), timeoutMs: stepTimeout() });
|
|
110
|
+
const ignored = Object.entries(allowed).map(([key, major]) => `${key} allowed at ${major}.x`);
|
|
111
|
+
const skippedScripts = request.allowScripts ? [] : skippedByPnpm(result.output);
|
|
112
|
+
if (result.code !== 0) {
|
|
113
|
+
const peer = /ERR_PNPM_PEER_DEP_ISSUES|unmet peer|Issues with peer dependencies/i.test(result.output);
|
|
114
|
+
return {
|
|
115
|
+
pass: false,
|
|
116
|
+
message: peer ? 'Peer conflict (pnpm strict-peer-dependencies).' : 'pnpm install failed.',
|
|
117
|
+
errorLines: errorLines(result.output),
|
|
118
|
+
fullOutput: result.output,
|
|
119
|
+
ignored,
|
|
120
|
+
environment: environmentProblem(result),
|
|
121
|
+
skippedScripts,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
pass: true,
|
|
126
|
+
message: ignored.length ? `Installed; allowed the expected conflict: ${ignored.join('; ')}.` : 'Installed.',
|
|
127
|
+
errorLines: [],
|
|
128
|
+
ignored,
|
|
129
|
+
skippedScripts,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function pnpmWorkspaceYaml(allowed, allowScripts = false) {
|
|
133
|
+
const lines = ['strictPeerDependencies: true', ...pnpmBuildSettings(allowScripts)];
|
|
134
|
+
if (Object.keys(allowed).length) {
|
|
135
|
+
lines.push('peerDependencyRules:', ' allowedVersions:');
|
|
136
|
+
for (const [key, range] of Object.entries(allowed))
|
|
137
|
+
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(range)}`);
|
|
138
|
+
}
|
|
139
|
+
return lines.join('\n') + '\n';
|
|
140
|
+
}
|
package/dist/library.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import * as tar from 'tar';
|
|
5
|
+
import { environmentProblem, errorLines, exec, npm, quietEnv } from "./exec.js";
|
|
6
|
+
export class LibraryError extends Error {
|
|
7
|
+
lines;
|
|
8
|
+
constructor(message, lines = []) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.lines = lines;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function readJson(path) {
|
|
14
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
/** `dest` from an ng-package.json (or a package.json `ngPackage` field): the same for the CLI, ng-packagr and Nx. */
|
|
17
|
+
export function ngPackageDest(ngPackagePath) {
|
|
18
|
+
const config = basename(ngPackagePath) === 'package.json'
|
|
19
|
+
? (readJson(ngPackagePath).ngPackage ?? {})
|
|
20
|
+
: readJson(ngPackagePath);
|
|
21
|
+
return resolve(dirname(ngPackagePath), config.dest ?? 'dist');
|
|
22
|
+
}
|
|
23
|
+
function resolveBin(cwd, pkg, bin) {
|
|
24
|
+
const require = createRequire(join(cwd, 'noop.js'));
|
|
25
|
+
let manifestPath;
|
|
26
|
+
try {
|
|
27
|
+
manifestPath = require.resolve(`${pkg}/package.json`);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new LibraryError(`${pkg} isn't installed in ${cwd}. Install the workspace's dependencies first.`);
|
|
31
|
+
}
|
|
32
|
+
const manifest = readJson(manifestPath);
|
|
33
|
+
const relativeBin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[bin];
|
|
34
|
+
if (!relativeBin)
|
|
35
|
+
throw new LibraryError(`${pkg} has no "${bin}" command.`);
|
|
36
|
+
return join(dirname(manifestPath), relativeBin);
|
|
37
|
+
}
|
|
38
|
+
export function detectBuild(options) {
|
|
39
|
+
const { cwd } = options;
|
|
40
|
+
const shell = (command) => () => exec(command, [], { cwd, shell: true });
|
|
41
|
+
const angularJson = join(cwd, 'angular.json');
|
|
42
|
+
if (existsSync(angularJson)) {
|
|
43
|
+
const workspace = readJson(angularJson);
|
|
44
|
+
const libraries = Object.entries(workspace.projects).filter(([, p]) => p.projectType === 'library');
|
|
45
|
+
if (!libraries.length)
|
|
46
|
+
throw new LibraryError('angular.json has no library project.');
|
|
47
|
+
let name = options.project;
|
|
48
|
+
if (!name) {
|
|
49
|
+
if (libraries.length > 1) {
|
|
50
|
+
throw new LibraryError(`This workspace has ${libraries.length} libraries (${libraries.map(([n]) => n).join(', ')}). Pick one with --project.`);
|
|
51
|
+
}
|
|
52
|
+
name = libraries[0][0];
|
|
53
|
+
}
|
|
54
|
+
const project = workspace.projects[name];
|
|
55
|
+
if (!project || project.projectType !== 'library')
|
|
56
|
+
throw new LibraryError(`No library project named "${name}" in angular.json.`);
|
|
57
|
+
const ngPackage = project.architect?.build?.options?.project ?? join(project.root ?? '', 'ng-package.json');
|
|
58
|
+
const dest = ngPackageDest(resolve(cwd, ngPackage));
|
|
59
|
+
if (options.build)
|
|
60
|
+
return { kind: 'angular-cli', label: options.build, dest, run: shell(options.build) };
|
|
61
|
+
return {
|
|
62
|
+
kind: 'angular-cli',
|
|
63
|
+
label: `ng build ${name} --configuration production`,
|
|
64
|
+
dest,
|
|
65
|
+
run: () => exec(process.execPath, [resolveBin(cwd, '@angular/cli', 'ng'), 'build', name, '--configuration', 'production'], {
|
|
66
|
+
cwd,
|
|
67
|
+
env: quietEnv(),
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const ngPackage = ['ng-package.json', 'package.json'].map((f) => join(cwd, f)).find((path) => {
|
|
72
|
+
if (!existsSync(path))
|
|
73
|
+
return false;
|
|
74
|
+
return basename(path) === 'ng-package.json' || Boolean(readJson(path).ngPackage);
|
|
75
|
+
});
|
|
76
|
+
if (ngPackage) {
|
|
77
|
+
const dest = ngPackageDest(ngPackage);
|
|
78
|
+
if (options.build)
|
|
79
|
+
return { kind: 'ng-packagr', label: options.build, dest, run: shell(options.build) };
|
|
80
|
+
const manifest = existsSync(join(cwd, 'package.json')) ? readJson(join(cwd, 'package.json')) : undefined;
|
|
81
|
+
if (manifest?.scripts?.build) {
|
|
82
|
+
return { kind: 'ng-packagr', label: 'npm run build', dest, run: () => npm(['run', 'build'], { cwd }) };
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
kind: 'ng-packagr',
|
|
86
|
+
label: 'ng-packagr -p ng-package.json',
|
|
87
|
+
dest,
|
|
88
|
+
run: () => exec(process.execPath, [resolveBin(cwd, 'ng-packagr', 'ng-packagr'), '-p', ngPackage], { cwd }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (existsSync(join(cwd, 'nx.json'))) {
|
|
92
|
+
throw new LibraryError('Nx workspaces are planned for v0.2. For now, run ng-matrix from the library folder (the one with ng-package.json) with --build "npx nx build <lib>".');
|
|
93
|
+
}
|
|
94
|
+
throw new LibraryError('No Angular library here: expected angular.json with a library project, or ng-package.json. Or pass --tarball / --package.');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Full compilation output must never be published: ng-packagr guards that with a `prepublishOnly`
|
|
98
|
+
* script, which `npm pack` doesn't run. So ng-matrix checks for it itself.
|
|
99
|
+
*/
|
|
100
|
+
export function fullCompilationEvidence(dir) {
|
|
101
|
+
const manifestPath = join(dir, 'package.json');
|
|
102
|
+
if (existsSync(manifestPath)) {
|
|
103
|
+
const guard = readJson(manifestPath).scripts?.prepublishOnly ?? '';
|
|
104
|
+
if (/full compilation mode/i.test(guard))
|
|
105
|
+
return 'its package.json has ng-packagr\'s "full compilation mode" prepublishOnly guard';
|
|
106
|
+
}
|
|
107
|
+
const define = /ɵɵdefine(Component|Directive|Pipe|NgModule)\(/;
|
|
108
|
+
const stack = [dir];
|
|
109
|
+
while (stack.length) {
|
|
110
|
+
const current = stack.pop();
|
|
111
|
+
for (const entry of readdirSync(current)) {
|
|
112
|
+
if (entry === 'node_modules')
|
|
113
|
+
continue;
|
|
114
|
+
const path = join(current, entry);
|
|
115
|
+
if (statSync(path).isDirectory())
|
|
116
|
+
stack.push(path);
|
|
117
|
+
else if (/\.m?js$/.test(entry)) {
|
|
118
|
+
const match = define.exec(readFileSync(path, 'utf8'));
|
|
119
|
+
if (match)
|
|
120
|
+
return `${path.slice(dir.length + 1)} calls ɵɵdefine${match[1]}, which only full compilation emits`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
async function pack(source, into) {
|
|
127
|
+
const result = await npm(['pack', source, '--pack-destination', into, '--json', '--ignore-scripts'], { cwd: into });
|
|
128
|
+
if (result.code !== 0)
|
|
129
|
+
throw new LibraryError(packFailure(source, result.output), errorLines(result.output));
|
|
130
|
+
const json = result.output.slice(result.output.indexOf('['));
|
|
131
|
+
const [info] = JSON.parse(json);
|
|
132
|
+
// Scoped packages: npm reports "@scope/name-1.0.0.tgz" but writes "scope-name-1.0.0.tgz".
|
|
133
|
+
return join(into, info.filename.replace(/^@/, '').replace('/', '-'));
|
|
134
|
+
}
|
|
135
|
+
/** What went wrong with `npm pack <spec>`, in words: most often the name or the version doesn't exist. */
|
|
136
|
+
export function packFailure(spec, output) {
|
|
137
|
+
if (/\bE404\b|404 Not Found/.test(output))
|
|
138
|
+
return `There's no package ${spec.replace(/(.)@[^@]*$/, '$1')} on the npm registry.`;
|
|
139
|
+
if (/\bETARGET\b|No matching version/.test(output))
|
|
140
|
+
return `${spec} doesn't match any published version.`;
|
|
141
|
+
const why = environmentProblem({ output, timedOut: false });
|
|
142
|
+
if (why)
|
|
143
|
+
return `Couldn't download ${spec}: ${why}.`;
|
|
144
|
+
return `npm pack ${spec} failed.`;
|
|
145
|
+
}
|
|
146
|
+
export async function extract(tarball, into) {
|
|
147
|
+
mkdirSync(into, { recursive: true });
|
|
148
|
+
try {
|
|
149
|
+
await tar.x({ file: tarball, cwd: into, strict: true });
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw new LibraryError(`${tarball} isn't a package tarball (.tgz): ${error.message}`);
|
|
153
|
+
}
|
|
154
|
+
const dir = join(into, 'package');
|
|
155
|
+
if (!existsSync(join(dir, 'package.json')))
|
|
156
|
+
throw new LibraryError(`${tarball} has no package/package.json.`);
|
|
157
|
+
return dir;
|
|
158
|
+
}
|
|
159
|
+
export async function prepareLibrary(options, workDir, log) {
|
|
160
|
+
const packDir = join(workDir, 'pack');
|
|
161
|
+
mkdirSync(packDir, { recursive: true });
|
|
162
|
+
let tarball;
|
|
163
|
+
let source;
|
|
164
|
+
if (options.tarball) {
|
|
165
|
+
tarball = resolve(options.cwd, options.tarball);
|
|
166
|
+
if (!existsSync(tarball))
|
|
167
|
+
throw new LibraryError(`No tarball at ${tarball}.`);
|
|
168
|
+
source = `tarball ${tarball}`;
|
|
169
|
+
}
|
|
170
|
+
else if (options.packageSpec) {
|
|
171
|
+
log(`Downloading ${options.packageSpec}…`);
|
|
172
|
+
tarball = await pack(options.packageSpec, packDir);
|
|
173
|
+
source = `${options.packageSpec} from the registry`;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const plan = detectBuild(options);
|
|
177
|
+
if (!options.skipBuild) {
|
|
178
|
+
log(`Building the library: ${plan.label}`);
|
|
179
|
+
const built = await plan.run();
|
|
180
|
+
if (built.code !== 0)
|
|
181
|
+
throw new LibraryError(`The library build failed (${plan.label}).`, errorLines(built.output));
|
|
182
|
+
}
|
|
183
|
+
if (!existsSync(join(plan.dest, 'package.json'))) {
|
|
184
|
+
throw new LibraryError(`No package.json in the build output ${plan.dest}. Is ng-package.json's "dest" right?`);
|
|
185
|
+
}
|
|
186
|
+
const full = fullCompilationEvidence(plan.dest);
|
|
187
|
+
if (full) {
|
|
188
|
+
throw new LibraryError(`Refusing to pack ${plan.dest}: it was built in full compilation mode (${full}). Libraries must be published in partial mode; build with the production configuration or set "compilationMode": "partial".`);
|
|
189
|
+
}
|
|
190
|
+
tarball = await pack(plan.dest, packDir);
|
|
191
|
+
source = `${plan.label}, then npm pack`;
|
|
192
|
+
}
|
|
193
|
+
const dir = await extract(tarball, join(packDir, 'contents'));
|
|
194
|
+
if (options.tarball || options.packageSpec) {
|
|
195
|
+
const full = fullCompilationEvidence(dir);
|
|
196
|
+
if (full)
|
|
197
|
+
throw new LibraryError(`This package was built in full compilation mode (${full}); Angular apps can't use it reliably.`);
|
|
198
|
+
}
|
|
199
|
+
return { manifest: readJson(join(dir, 'package.json')), tarball, dir, source };
|
|
200
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const CHECKS = ['peer', 'install', 'partial', 'aot', 'strict', 'tests'];
|
|
2
|
+
export const CHECK_LABELS = {
|
|
3
|
+
peer: 'peer range',
|
|
4
|
+
install: 'install',
|
|
5
|
+
partial: 'partial',
|
|
6
|
+
aot: 'AOT build',
|
|
7
|
+
strict: 'strict types',
|
|
8
|
+
tests: 'tests',
|
|
9
|
+
};
|
|
10
|
+
export const SCHEMA_VERSION = 1;
|
|
11
|
+
export function result(status, message = '', extra = {}) {
|
|
12
|
+
return { status, message, errorLines: [], durationMs: 0, ...extra };
|
|
13
|
+
}
|
package/dist/partial.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import semver from 'semver';
|
|
4
|
+
const DECLARE = /ɵɵngDeclare(\w+)\(\s*\{/g;
|
|
5
|
+
export function scanSource(source, file) {
|
|
6
|
+
const found = [];
|
|
7
|
+
for (const match of source.matchAll(DECLARE)) {
|
|
8
|
+
// The fields come first in every declaration, well within this window.
|
|
9
|
+
const body = source.slice(match.index + match[0].length, match.index + match[0].length + 800);
|
|
10
|
+
const minVersion = /minVersion:\s*["']([^"']+)["']/.exec(body)?.[1];
|
|
11
|
+
const version = /\bversion:\s*["']([^"']+)["']/.exec(body)?.[1];
|
|
12
|
+
const type = /\btype:\s*([\w$]+)/.exec(body)?.[1] ?? '?';
|
|
13
|
+
if (minVersion && version)
|
|
14
|
+
found.push({ kind: match[1], type, minVersion, version, file });
|
|
15
|
+
}
|
|
16
|
+
return found;
|
|
17
|
+
}
|
|
18
|
+
export function scanPackage(dir) {
|
|
19
|
+
const found = [];
|
|
20
|
+
const walk = (current) => {
|
|
21
|
+
for (const entry of readdirSync(current)) {
|
|
22
|
+
if (entry === 'node_modules')
|
|
23
|
+
continue;
|
|
24
|
+
const path = join(current, entry);
|
|
25
|
+
if (statSync(path).isDirectory())
|
|
26
|
+
walk(path);
|
|
27
|
+
else if (/\.(mjs|js|cjs)$/.test(entry))
|
|
28
|
+
found.push(...scanSource(readFileSync(path, 'utf8'), relative(dir, path)));
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
walk(dir);
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
export function summarize(declarations) {
|
|
35
|
+
let highest;
|
|
36
|
+
let compiledWith;
|
|
37
|
+
for (const declaration of declarations) {
|
|
38
|
+
const min = semver.coerce(declaration.minVersion);
|
|
39
|
+
if (min && (!highest || semver.gt(min, semver.coerce(highest.minVersion))))
|
|
40
|
+
highest = declaration;
|
|
41
|
+
// Angular's own source builds say 0.0.0-PLACEHOLDER; that tells us nothing.
|
|
42
|
+
if (semver.valid(declaration.version) && !declaration.version.startsWith('0.0.0')) {
|
|
43
|
+
if (!compiledWith || semver.gt(declaration.version, compiledWith))
|
|
44
|
+
compiledWith = declaration.version;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { declarations: declarations.length, highest, compiledWith };
|
|
48
|
+
}
|
|
49
|
+
export function judgePartial(summary, angularVersion) {
|
|
50
|
+
const app = semver.coerce(angularVersion);
|
|
51
|
+
if (!summary.highest) {
|
|
52
|
+
return { pass: true, advisory: false, message: 'No Angular declarations in the package.' };
|
|
53
|
+
}
|
|
54
|
+
const { highest } = summary;
|
|
55
|
+
const where = `${highest.type} (ɵɵngDeclare${highest.kind}, ${highest.file})`;
|
|
56
|
+
if (semver.lt(app, semver.coerce(highest.minVersion))) {
|
|
57
|
+
return {
|
|
58
|
+
pass: false,
|
|
59
|
+
advisory: false,
|
|
60
|
+
message: `Needs Angular ${highest.minVersion} or later: highest minVersion ${highest.minVersion}, in ${where}.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const advisory = Boolean(summary.compiledWith && semver.major(summary.compiledWith) > app.major);
|
|
64
|
+
return {
|
|
65
|
+
pass: true,
|
|
66
|
+
advisory,
|
|
67
|
+
message: advisory
|
|
68
|
+
? `Highest minVersion ${highest.minVersion} allows it, but the package was compiled with Angular ${summary.compiledWith} and minVersion has been too low before, so only the build can tell.`
|
|
69
|
+
: `Highest minVersion ${highest.minVersion}, in ${where}.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** The `@angular/*` packages the published JavaScript imports (`@angular/common/http` counts as `@angular/common`). */
|
|
73
|
+
export function angularImports(dir) {
|
|
74
|
+
const found = new Set();
|
|
75
|
+
const specifier = /(?:\bfrom|\bimport)\s*\(?\s*["'](@angular\/[\w.-]+)(?:\/[^"']*)?["']/g;
|
|
76
|
+
const walk = (current) => {
|
|
77
|
+
for (const entry of readdirSync(current)) {
|
|
78
|
+
if (entry === 'node_modules')
|
|
79
|
+
continue;
|
|
80
|
+
const path = join(current, entry);
|
|
81
|
+
if (statSync(path).isDirectory())
|
|
82
|
+
walk(path);
|
|
83
|
+
else if (/\.(mjs|js|cjs)$/.test(entry))
|
|
84
|
+
for (const match of readFileSync(path, 'utf8').matchAll(specifier))
|
|
85
|
+
found.add(match[1]);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
walk(dir);
|
|
89
|
+
return [...found].sort();
|
|
90
|
+
}
|