angular-compat-matrix 0.1.0 → 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.
@@ -0,0 +1,117 @@
1
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { killAllChildren } from "./exec.js";
5
+ /*
6
+ * Temporary apps and half-written cache entries must not outlive a run, however it ends:
7
+ * normally, with Ctrl+C, or killed outright. Each work folder records the pid that owns it,
8
+ * and each cache write goes through `<name>.<pid>.tmp`, so whatever a dead run left behind
9
+ * can be told apart from what a running one is still using.
10
+ */
11
+ const PREFIX = 'ng-matrix-';
12
+ const OWNER = '.ng-matrix-pid';
13
+ export function createWorkDir() {
14
+ const dir = mkdtempSync(join(tmpdir(), PREFIX));
15
+ writeFileSync(join(dir, OWNER), String(process.pid));
16
+ return dir;
17
+ }
18
+ export function isAlive(pid) {
19
+ if (!Number.isInteger(pid) || pid <= 0)
20
+ return false;
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ return error.code === 'EPERM';
27
+ }
28
+ }
29
+ export function removeDir(dir) {
30
+ // On Windows a just-killed build can hold files open for a moment.
31
+ rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
32
+ }
33
+ /** Removes work folders whose run is gone. Folders without an owner file (0.1.0) go after a day. */
34
+ export function sweepWorkDirs(base = tmpdir()) {
35
+ const removed = [];
36
+ let entries;
37
+ try {
38
+ entries = readdirSync(base).filter((name) => name.startsWith(PREFIX));
39
+ }
40
+ catch {
41
+ return removed;
42
+ }
43
+ for (const name of entries) {
44
+ const dir = join(base, name);
45
+ try {
46
+ const owner = join(dir, OWNER);
47
+ const stale = existsSync(owner)
48
+ ? !isAlive(Number(readFileSync(owner, 'utf8')))
49
+ : Date.now() - statSync(dir).mtimeMs > 24 * 60 * 60 * 1000;
50
+ if (stale) {
51
+ removeDir(dir);
52
+ removed.push(dir);
53
+ }
54
+ }
55
+ catch {
56
+ // Not ours to judge, or already gone.
57
+ }
58
+ }
59
+ return removed;
60
+ }
61
+ /** Removes `<name>.<pid>.tmp` entries in the cache left by runs that are gone. */
62
+ export function sweepCache(cacheDir) {
63
+ const removed = [];
64
+ for (const sub of ['tools', 'registry']) {
65
+ const dir = join(cacheDir, sub);
66
+ let entries;
67
+ try {
68
+ entries = readdirSync(dir);
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ for (const name of entries) {
74
+ const match = /\.(\d+)\.tmp$/.exec(name);
75
+ if (!match || isAlive(Number(match[1])))
76
+ continue;
77
+ try {
78
+ removeDir(join(dir, name));
79
+ removed.push(join(dir, name));
80
+ }
81
+ catch {
82
+ // Try again next run.
83
+ }
84
+ }
85
+ }
86
+ return removed;
87
+ }
88
+ /**
89
+ * On Ctrl+C (or SIGTERM, or a closed terminal): stop every child, remove the work folder unless
90
+ * --keep, and exit. A second Ctrl+C exits at once.
91
+ */
92
+ export function cleanUpOnInterrupt(workDir, keep, log) {
93
+ let stopping = false;
94
+ const handler = (signal) => {
95
+ if (stopping)
96
+ process.exit(130);
97
+ stopping = true;
98
+ log(`Interrupted (${signal}): stopping, then ${keep ? `keeping ${workDir} (--keep)` : `removing ${workDir}`}.`);
99
+ killAllChildren();
100
+ if (!keep) {
101
+ try {
102
+ removeDir(workDir);
103
+ }
104
+ catch (error) {
105
+ log(`Couldn't remove ${workDir}: ${error.message}. The next run removes it.`);
106
+ }
107
+ }
108
+ process.exit(signal === 'SIGINT' || signal === 'SIGBREAK' ? 130 : 143);
109
+ };
110
+ const signals = process.platform === 'win32' ? ['SIGINT', 'SIGTERM', 'SIGBREAK'] : ['SIGINT', 'SIGTERM', 'SIGHUP'];
111
+ for (const signal of signals)
112
+ process.on(signal, handler);
113
+ return () => {
114
+ for (const signal of signals)
115
+ process.off(signal, handler);
116
+ };
117
+ }
package/dist/cli.js CHANGED
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { cleanUpOnInterrupt, createWorkDir, removeDir, sweepCache, sweepWorkDirs } from "./cleanup.js";
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseArgs } from 'node:util';
7
7
  import semver from 'semver';
8
8
  import { LibraryError, prepareLibrary } from "./library.js";
9
+ import { SCHEMA_VERSION } from "./model.js";
9
10
  import { angularImports, scanPackage, summarize } from "./partial.js";
10
- import { Registry } from "./registry.js";
11
+ import { Registry, RegistryError } from "./registry.js";
11
12
  import { exitCode, findings, renderJson, renderMarkdown, renderTerminal } from "./report.js";
12
13
  import { defaultConcurrency, runAll } from "./run.js";
13
- import { defaultCacheDir } from "./tools.js";
14
+ import { setStepTimeout, setVerbose } from "./exec.js";
15
+ import { Progress } from "./progress.js";
16
+ import { ALLOW_SCRIPTS_WARNING } from "./scripts.js";
17
+ import { cacheStats, cleanCache, defaultCacheDir, formatBytes } from "./tools.js";
14
18
  import { defaultMajors, describeMajor, loadVersionSources, majorsOf, nextVersion, nodeSatisfies, parseVersionList, } from "./versions.js";
15
19
  const HELP = `ng-matrix: which Angular versions does your published library really work with?
16
20
 
@@ -20,6 +24,8 @@ major (made with that major's own CLI), builds it, and reports a matrix.
20
24
  Usage
21
25
  npx angular-compat-matrix [options]
22
26
  ng-matrix [options] (once installed)
27
+ ng-matrix cache (show cache location and size)
28
+ ng-matrix cache clean (delete the cache)
23
29
 
24
30
  Options
25
31
  --versions <list> Angular majors to check: 22, 18-22, 20,22. Default: the supported
@@ -30,6 +36,7 @@ Options
30
36
  --tests Also run the library's tests on each version (slow)
31
37
  --test-command <cmd> The test command for --tests (default: <pm> test)
32
38
  --format <format> terminal (default), markdown or json
39
+ --json Shorthand for --format json
33
40
  --pm <npm|pnpm> Package manager for the apps (default: pnpm if there's a
34
41
  pnpm-lock.yaml, else npm)
35
42
  --build <cmd> Build the library with this command instead of the detected one
@@ -37,10 +44,13 @@ Options
37
44
  --tarball <file> Check this packed .tgz instead of building
38
45
  --package <spec> Check a published package, e.g. some-lib@3.1.0
39
46
  --concurrency <n> Versions checked at once (default: as many as free memory allows)
47
+ --step-timeout <min> Longest any one step (ng new, install, build, tests) may take (default: 20)
40
48
  --keep Keep the temporary apps, for debugging
49
+ --allow-scripts Let the package's (and its dependencies') install scripts run. By
50
+ default only the Angular toolchain's own builds run (esbuild, lmdb...)
41
51
  --offline Use only cached registry answers and packages
42
52
  --cwd <dir> The library's workspace (default: the current folder)
43
- --verbose Show every error line, not just the first few
53
+ --verbose Show commands run and full build/install output for failures
44
54
  -h, --help This help
45
55
  -v, --version ng-matrix's version
46
56
 
@@ -66,6 +76,9 @@ function explain(error) {
66
76
  if (error instanceof Stop || error instanceof LibraryError) {
67
77
  process.stderr.write(`ng-matrix: ${error.message}\n${error.lines.map((l) => ` ${l}\n`).join('')}`);
68
78
  }
79
+ else if (error instanceof RegistryError) {
80
+ process.stderr.write(`ng-matrix: ${error.message}\n`);
81
+ }
69
82
  else if (String(error.code).startsWith('ERR_PARSE_ARGS')) {
70
83
  process.stderr.write(`ng-matrix: ${error.message}. See --help.\n`);
71
84
  }
@@ -85,13 +98,16 @@ export async function main(argv) {
85
98
  consumer: { type: 'string' },
86
99
  tests: { type: 'boolean', default: false },
87
100
  'test-command': { type: 'string' },
101
+ 'allow-scripts': { type: 'boolean', default: false },
88
102
  format: { type: 'string', default: 'terminal' },
103
+ json: { type: 'boolean', default: false },
89
104
  pm: { type: 'string' },
90
105
  build: { type: 'string' },
91
106
  'skip-build': { type: 'boolean', default: false },
92
107
  tarball: { type: 'string' },
93
108
  package: { type: 'string' },
94
109
  concurrency: { type: 'string' },
110
+ 'step-timeout': { type: 'string' },
95
111
  keep: { type: 'boolean', default: false },
96
112
  offline: { type: 'boolean', default: false },
97
113
  cwd: { type: 'string' },
@@ -108,28 +124,58 @@ export async function main(argv) {
108
124
  process.stdout.write(`${toolVersion()}\n`);
109
125
  return 0;
110
126
  }
127
+ if (positionals[0] === 'cache') {
128
+ const cacheDir = defaultCacheDir();
129
+ const sub = positionals[1];
130
+ if (!sub) {
131
+ const stats = cacheStats(cacheDir);
132
+ process.stdout.write(`Location: ${cacheDir}\nSize: ${formatBytes(stats.bytes)} (${stats.count} files)\n`);
133
+ return 0;
134
+ }
135
+ if (sub === 'clean') {
136
+ cleanCache(cacheDir);
137
+ process.stdout.write(`Cleaned cache at ${cacheDir}\n`);
138
+ return 0;
139
+ }
140
+ fail(`Unknown cache command "ng-matrix cache ${sub}". See --help.`);
141
+ }
111
142
  if (positionals[0] === 'ci')
112
143
  fail('`ng-matrix ci github` is planned for v0.2.');
113
144
  if (positionals.length)
114
145
  fail(`Unknown argument "${positionals[0]}". See --help.`);
115
- const format = values.format;
146
+ const format = values.json ? 'json' : values.format;
116
147
  if (!['terminal', 'markdown', 'json'].includes(format))
117
148
  fail(`--format must be terminal, markdown or json, not "${format}".`);
149
+ if (values.verbose)
150
+ setVerbose(true);
118
151
  const cwd = resolve(values.cwd ?? process.cwd());
119
152
  const pm = (values.pm ?? (existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm'));
120
153
  if (pm !== 'npm' && pm !== 'pnpm')
121
154
  fail(`--pm must be npm or pnpm (yarn and bun come later), not "${pm}".`);
122
155
  const consumer = values.consumer ? resolve(cwd, values.consumer) : undefined;
123
- const log = (line) => process.stderr.write(`[ng-matrix] ${line}\n`);
156
+ if (values['step-timeout'] !== undefined) {
157
+ const minutes = Number(values['step-timeout']);
158
+ if (!(minutes > 0))
159
+ fail(`--step-timeout must be a number of minutes above 0, not "${values['step-timeout']}".`);
160
+ setStepTimeout(minutes * 60_000);
161
+ }
162
+ const progress = new Progress(format !== 'json');
163
+ const log = (line) => progress.log(`[ng-matrix] ${line}`);
124
164
  const cacheDir = defaultCacheDir();
125
165
  const registry = new Registry({
126
166
  cacheDir,
127
167
  offline: values.offline,
128
168
  recordedDir: process.env.NG_MATRIX_RECORDED_REGISTRY,
129
169
  });
130
- const workDir = mkdtempSync(join(tmpdir(), 'ng-matrix-'));
170
+ // Leftovers from runs that were killed before they could clean up.
171
+ sweepWorkDirs();
172
+ sweepCache(cacheDir);
173
+ const workDir = createWorkDir();
174
+ const stopListening = cleanUpOnInterrupt(workDir, values.keep, log);
131
175
  if (values.keep)
132
176
  log(`Working in ${workDir} (--keep)`);
177
+ if (values['allow-scripts'])
178
+ log(ALLOW_SCRIPTS_WARNING);
133
179
  try {
134
180
  const library = await prepareLibrary({
135
181
  cwd,
@@ -175,9 +221,12 @@ export async function main(argv) {
175
221
  testCommand: values['test-command'],
176
222
  consumer,
177
223
  keep: values.keep,
224
+ allowScripts: values['allow-scripts'],
178
225
  log,
226
+ progress,
179
227
  }, angular, concurrency);
180
228
  const report = {
229
+ schemaVersion: SCHEMA_VERSION,
181
230
  tool: { name: 'angular-compat-matrix', version: toolVersion() },
182
231
  checkedAt: now.toISOString(),
183
232
  library: {
@@ -190,12 +239,13 @@ export async function main(argv) {
190
239
  declarations: summary.declarations,
191
240
  },
192
241
  packageManager: pm,
242
+ installScripts: values['allow-scripts'] ? 'all' : 'toolchain-only',
193
243
  node: process.version,
194
244
  versions,
195
245
  findings: findings(versions, library.manifest, angularImports(library.dir)),
196
246
  exitCode: exitCode(versions),
197
247
  };
198
- const render = format === 'json' ? renderJson : format === 'markdown' ? renderMarkdown : (r) => renderTerminal(r, values.verbose);
248
+ const render = format === 'json' ? (r) => renderJson(r, values.verbose) : format === 'markdown' ? renderMarkdown : (r) => renderTerminal(r, values.verbose);
199
249
  process.stdout.write((format === 'terminal' ? '\n' : '') + render(report));
200
250
  return report.exitCode;
201
251
  }
@@ -203,8 +253,10 @@ export async function main(argv) {
203
253
  return explain(error);
204
254
  }
205
255
  finally {
256
+ progress.stop();
257
+ stopListening();
206
258
  if (!values.keep)
207
- rmSync(workDir, { recursive: true, force: true, maxRetries: 3 });
259
+ removeDir(workDir);
208
260
  }
209
261
  }
210
262
  main(process.argv.slice(2)).then((code) => process.exit(code), (error) => process.exit(explain(error)));
package/dist/exec.js CHANGED
@@ -19,12 +19,35 @@ export function quietEnv(extra = {}) {
19
19
  ...extra,
20
20
  };
21
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
+ }
22
37
  export function exec(command, args, options) {
23
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
+ }
24
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';
25
47
  const child = options.shell
26
- ? spawn([command, ...args].join(' '), { cwd: options.cwd, env: options.env ?? quietEnv(), shell: true })
27
- : spawn(command, args, { cwd: options.cwd, env: options.env ?? quietEnv() });
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);
28
51
  let output = '';
29
52
  let timedOut = false;
30
53
  const append = (chunk) => (output += chunk.toString('utf8'));
@@ -37,6 +60,7 @@ export function exec(command, args, options) {
37
60
  }, options.timeoutMs)
38
61
  : undefined;
39
62
  const finish = (code) => {
63
+ running.delete(child);
40
64
  if (timer)
41
65
  clearTimeout(timer);
42
66
  resolve({ code, output: stripAnsi(output), durationMs: Date.now() - started, timedOut });
@@ -56,10 +80,15 @@ function killTree(pid) {
56
80
  }
57
81
  else {
58
82
  try {
59
- process.kill(pid, 'SIGKILL');
83
+ process.kill(-pid, 'SIGKILL');
60
84
  }
61
85
  catch {
62
- // already gone
86
+ try {
87
+ process.kill(pid, 'SIGKILL');
88
+ }
89
+ catch {
90
+ // already gone
91
+ }
63
92
  }
64
93
  }
65
94
  }
@@ -108,3 +137,30 @@ export function errorLines(output, max = 12) {
108
137
  }
109
138
  return lines.filter((line) => line.trim()).slice(-max);
110
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 CHANGED
@@ -2,7 +2,8 @@ import { copyFileSync, writeFileSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
3
  import semver from 'semver';
4
4
  import { readJson, writeJson } from "./app.js";
5
- import { errorLines, exec, npm, quietEnv } from "./exec.js";
5
+ import { environmentProblem, errorLines, exec, npm, quietEnv, stepTimeout } from "./exec.js";
6
+ import { npmInstall, pnpmBuildSettings, skippedByNpm, skippedByPnpm } from "./scripts.js";
6
7
  import { ensureTool, PNPM_VERSION, pnpmEntry } from "./tools.js";
7
8
  export function addDependencies(appDir, deps, extra = {}) {
8
9
  const path = join(appDir, 'package.json');
@@ -15,7 +16,7 @@ export async function installLibrary(request) {
15
16
  const tgz = basename(request.tarball);
16
17
  copyFileSync(request.tarball, join(request.appDir, tgz));
17
18
  const deps = { ...request.plan.installs, [request.libraryName]: `file:${tgz}` };
18
- return request.pm === 'npm' ? installWithNpm(request, deps) : installWithPnpm(request, deps, tgz);
19
+ return request.pm === 'npm' ? installWithNpm(request, deps) : installWithPnpm(request, deps);
19
20
  }
20
21
  /**
21
22
  * npm 11 fails on any peer conflict and can't ignore just one. So: install without peer
@@ -26,13 +27,22 @@ async function installWithNpm(request, deps) {
26
27
  const args = ['install', '--legacy-peer-deps', '--no-audit', '--no-fund'];
27
28
  if (request.offline)
28
29
  args.push('--offline');
29
- const result = await npm(args, { cwd: request.appDir, timeoutMs: 15 * 60_000 });
30
+ const result = await npmInstall(args, { cwd: request.appDir, timeoutMs: stepTimeout() }, request.allowScripts);
31
+ const skippedScripts = request.allowScripts ? [] : skippedByNpm(request.appDir);
30
32
  if (result.code !== 0) {
31
- return { pass: false, message: 'npm install failed.', errorLines: errorLines(result.output), ignored: [] };
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
+ };
32
42
  }
33
43
  const ls = await npm(['ls', '--all'], { cwd: request.appDir });
34
44
  const problems = parseNpmLs(ls.output);
35
- return judgeProblems(request, problems);
45
+ return { ...judgeProblems(request, problems), skippedScripts };
36
46
  }
37
47
  /** Reads `npm ls --all` text output: `@angular/core@21.2.25 deduped invalid: "^22.0.0" from node_modules/@angular/cdk`. */
38
48
  export function parseNpmLs(output) {
@@ -79,10 +89,10 @@ function judgeProblems(request, problems) {
79
89
  }
80
90
  /**
81
91
  * pnpm only warns about peers by default, so strictPeerDependencies is on, with allowedVersions
82
- * for the one expected conflict. strictDepBuilds is off: otherwise pnpm 12 fails every install
83
- * on Angular's own esbuild, lmdb and @parcel/watcher scripts. The library's own scripts may run.
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.
84
94
  */
85
- async function installWithPnpm(request, deps, tgz) {
95
+ async function installWithPnpm(request, deps) {
86
96
  addDependencies(request.appDir, deps, { packageManager: `pnpm@${PNPM_VERSION}` });
87
97
  const allowed = {};
88
98
  if (request.plan.claimed === false) {
@@ -91,20 +101,24 @@ async function installWithPnpm(request, deps, tgz) {
91
101
  allowed[`${request.libraryName}>${miss.name}`] = major;
92
102
  }
93
103
  }
94
- writeFileSync(join(request.appDir, 'pnpm-workspace.yaml'), pnpmWorkspaceYaml(request.libraryName, tgz, allowed));
104
+ writeFileSync(join(request.appDir, 'pnpm-workspace.yaml'), pnpmWorkspaceYaml(allowed, request.allowScripts));
95
105
  const pnpm = await ensureTool(request.cacheDir, 'pnpm', PNPM_VERSION, request.offline);
96
106
  const args = [pnpmEntry(pnpm), 'install', '--no-frozen-lockfile'];
97
107
  if (request.offline)
98
108
  args.push('--offline');
99
- const result = await exec(process.execPath, args, { cwd: request.appDir, env: quietEnv(), timeoutMs: 15 * 60_000 });
109
+ const result = await exec(process.execPath, args, { cwd: request.appDir, env: quietEnv(), timeoutMs: stepTimeout() });
100
110
  const ignored = Object.entries(allowed).map(([key, major]) => `${key} allowed at ${major}.x`);
111
+ const skippedScripts = request.allowScripts ? [] : skippedByPnpm(result.output);
101
112
  if (result.code !== 0) {
102
113
  const peer = /ERR_PNPM_PEER_DEP_ISSUES|unmet peer|Issues with peer dependencies/i.test(result.output);
103
114
  return {
104
115
  pass: false,
105
116
  message: peer ? 'Peer conflict (pnpm strict-peer-dependencies).' : 'pnpm install failed.',
106
117
  errorLines: errorLines(result.output),
118
+ fullOutput: result.output,
107
119
  ignored,
120
+ environment: environmentProblem(result),
121
+ skippedScripts,
108
122
  };
109
123
  }
110
124
  return {
@@ -112,16 +126,11 @@ async function installWithPnpm(request, deps, tgz) {
112
126
  message: ignored.length ? `Installed; allowed the expected conflict: ${ignored.join('; ')}.` : 'Installed.',
113
127
  errorLines: [],
114
128
  ignored,
129
+ skippedScripts,
115
130
  };
116
131
  }
117
- export function pnpmWorkspaceYaml(libraryName, tgz, allowed) {
118
- const lines = [
119
- 'strictPeerDependencies: true',
120
- 'strictDepBuilds: false',
121
- 'allowBuilds:',
122
- // pnpm keys a tarball dependency by its full spec; the bare name doesn't match it.
123
- ` ${JSON.stringify(`${libraryName}@file:${tgz}`)}: true`,
124
- ];
132
+ export function pnpmWorkspaceYaml(allowed, allowScripts = false) {
133
+ const lines = ['strictPeerDependencies: true', ...pnpmBuildSettings(allowScripts)];
125
134
  if (Object.keys(allowed).length) {
126
135
  lines.push('peerDependencyRules:', ' allowedVersions:');
127
136
  for (const [key, range] of Object.entries(allowed))
package/dist/library.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node
2
2
  import { createRequire } from 'node:module';
3
3
  import { basename, dirname, join, resolve } from 'node:path';
4
4
  import * as tar from 'tar';
5
- import { errorLines, exec, npm, quietEnv } from "./exec.js";
5
+ import { environmentProblem, errorLines, exec, npm, quietEnv } from "./exec.js";
6
6
  export class LibraryError extends Error {
7
7
  lines;
8
8
  constructor(message, lines = []) {
@@ -126,15 +126,31 @@ export function fullCompilationEvidence(dir) {
126
126
  async function pack(source, into) {
127
127
  const result = await npm(['pack', source, '--pack-destination', into, '--json', '--ignore-scripts'], { cwd: into });
128
128
  if (result.code !== 0)
129
- throw new LibraryError(`npm pack ${source} failed.`, errorLines(result.output));
129
+ throw new LibraryError(packFailure(source, result.output), errorLines(result.output));
130
130
  const json = result.output.slice(result.output.indexOf('['));
131
131
  const [info] = JSON.parse(json);
132
132
  // Scoped packages: npm reports "@scope/name-1.0.0.tgz" but writes "scope-name-1.0.0.tgz".
133
133
  return join(into, info.filename.replace(/^@/, '').replace('/', '-'));
134
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
+ }
135
146
  export async function extract(tarball, into) {
136
147
  mkdirSync(into, { recursive: true });
137
- await tar.x({ file: tarball, cwd: into });
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
+ }
138
154
  const dir = join(into, 'package');
139
155
  if (!existsSync(join(dir, 'package.json')))
140
156
  throw new LibraryError(`${tarball} has no package/package.json.`);
package/dist/model.js CHANGED
@@ -7,6 +7,7 @@ export const CHECK_LABELS = {
7
7
  strict: 'strict types',
8
8
  tests: 'tests',
9
9
  };
10
+ export const SCHEMA_VERSION = 1;
10
11
  export function result(status, message = '', extra = {}) {
11
12
  return { status, message, errorLines: [], durationMs: 0, ...extra };
12
13
  }
@@ -0,0 +1,65 @@
1
+ export class Progress {
2
+ active = new Map();
3
+ timer;
4
+ isTTY;
5
+ rendered = false;
6
+ constructor(enabled) {
7
+ this.isTTY = enabled && Boolean(process.stderr.isTTY);
8
+ if (this.isTTY) {
9
+ this.timer = setInterval(() => this.render(), 1000);
10
+ this.timer.unref();
11
+ }
12
+ }
13
+ step(major, step) {
14
+ this.active.set(major, { step, started: Date.now() });
15
+ if (this.isTTY)
16
+ this.render();
17
+ }
18
+ done(major) {
19
+ this.active.delete(major);
20
+ if (this.isTTY)
21
+ this.render();
22
+ }
23
+ log(line) {
24
+ if (this.isTTY) {
25
+ this.clear();
26
+ process.stderr.write(`${line}\n`);
27
+ this.render();
28
+ }
29
+ else {
30
+ process.stderr.write(`${line}\n`);
31
+ }
32
+ }
33
+ render() {
34
+ if (!this.isTTY)
35
+ return;
36
+ if (this.active.size === 0) {
37
+ this.clear();
38
+ return;
39
+ }
40
+ const parts = [];
41
+ const now = Date.now();
42
+ for (const [major, item] of this.active) {
43
+ const elapsed = Math.floor((now - item.started) / 1000);
44
+ parts.push(`Angular ${major}: ${item.step} (${elapsed}s)`);
45
+ }
46
+ const text = `[ng-matrix] ${parts.join(' | ')}`;
47
+ process.stderr.write(`\r\x1b[K${text}`);
48
+ this.rendered = true;
49
+ }
50
+ clear() {
51
+ if (this.rendered) {
52
+ process.stderr.write('\r\x1b[K');
53
+ this.rendered = false;
54
+ }
55
+ }
56
+ stop() {
57
+ if (this.timer) {
58
+ clearInterval(this.timer);
59
+ this.timer = undefined;
60
+ }
61
+ if (this.isTTY) {
62
+ this.clear();
63
+ }
64
+ }
65
+ }