checkly 8.22.0 → 8.23.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/dist/ai-context/skills-command/references/configure-playwright-checks.md +24 -19
- package/dist/commands/debug/parse-project.js +3 -0
- package/dist/commands/debug/parse-project.js.map +1 -1
- package/dist/commands/deploy.js +3 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/pw-test.js +3 -0
- package/dist/commands/pw-test.js.map +1 -1
- package/dist/commands/test.js +3 -0
- package/dist/commands/test.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js.map +1 -1
- package/dist/constructs/playwright-check-bundle.d.ts +5 -5
- package/dist/constructs/playwright-check-bundle.js.map +1 -1
- package/dist/constructs/project.js +5 -2
- package/dist/constructs/project.js.map +1 -1
- package/dist/constructs/session.d.ts +2 -1
- package/dist/constructs/session.js +2 -1
- package/dist/constructs/session.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/services/check-parser/bundler.d.ts +79 -8
- package/dist/services/check-parser/bundler.js +567 -9
- package/dist/services/check-parser/bundler.js.map +1 -1
- package/dist/services/check-parser/cache-hash.d.ts +141 -17
- package/dist/services/check-parser/cache-hash.js +78 -12
- package/dist/services/check-parser/cache-hash.js.map +1 -1
- package/dist/services/check-parser/faux-package.d.ts +5 -0
- package/dist/services/check-parser/faux-package.js +9 -1
- package/dist/services/check-parser/faux-package.js.map +1 -1
- package/dist/services/check-parser/lockfile-pruner.d.ts +104 -0
- package/dist/services/check-parser/lockfile-pruner.js +1107 -0
- package/dist/services/check-parser/lockfile-pruner.js.map +1 -0
- package/dist/services/check-parser/package-files/package-manager.d.ts +34 -0
- package/dist/services/check-parser/package-files/package-manager.js +131 -3
- package/dist/services/check-parser/package-files/package-manager.js.map +1 -1
- package/dist/services/check-parser/package-files/pnpmfile.d.ts +40 -0
- package/dist/services/check-parser/package-files/pnpmfile.js +322 -0
- package/dist/services/check-parser/package-files/pnpmfile.js.map +1 -0
- package/dist/services/check-parser/package-files/resolver.d.ts +6 -1
- package/dist/services/check-parser/package-files/resolver.js +34 -0
- package/dist/services/check-parser/package-files/resolver.js.map +1 -1
- package/dist/services/check-parser/package-files/workspace.d.ts +16 -1
- package/dist/services/check-parser/package-files/workspace.js +17 -2
- package/dist/services/check-parser/package-files/workspace.js.map +1 -1
- package/dist/services/check-parser/package-prune.d.ts +68 -0
- package/dist/services/check-parser/package-prune.js +274 -0
- package/dist/services/check-parser/package-prune.js.map +1 -0
- package/dist/services/check-parser/parser.js +9 -0
- package/dist/services/check-parser/parser.js.map +1 -1
- package/dist/services/check-parser/patched-dependencies.d.ts +133 -0
- package/dist/services/check-parser/patched-dependencies.js +428 -0
- package/dist/services/check-parser/patched-dependencies.js.map +1 -0
- package/dist/services/checkly-config-loader.d.ts +164 -10
- package/dist/services/checkly-config-loader.js +39 -0
- package/dist/services/checkly-config-loader.js.map +1 -1
- package/dist/services/embedded-packages/cache.d.ts +8 -4
- package/dist/services/embedded-packages/cache.js +27 -6
- package/dist/services/embedded-packages/cache.js.map +1 -1
- package/dist/services/embedded-packages/diagnostics.d.ts +129 -0
- package/dist/services/embedded-packages/diagnostics.js +212 -0
- package/dist/services/embedded-packages/diagnostics.js.map +1 -0
- package/dist/services/embedded-packages/lockfile-filter.d.ts +20 -0
- package/dist/services/embedded-packages/lockfile-filter.js +40 -0
- package/dist/services/embedded-packages/lockfile-filter.js.map +1 -0
- package/dist/services/embedded-packages/lockfile-packages.d.ts +31 -2
- package/dist/services/embedded-packages/lockfile-packages.js +234 -30
- package/dist/services/embedded-packages/lockfile-packages.js.map +1 -1
- package/dist/services/embedded-packages/materializer.d.ts +19 -5
- package/dist/services/embedded-packages/materializer.js +445 -91
- package/dist/services/embedded-packages/materializer.js.map +1 -1
- package/dist/services/embedded-packages/npmrc.d.ts +163 -21
- package/dist/services/embedded-packages/npmrc.js +440 -75
- package/dist/services/embedded-packages/npmrc.js.map +1 -1
- package/dist/services/embedded-packages/spec.d.ts +89 -17
- package/dist/services/embedded-packages/spec.js +199 -30
- package/dist/services/embedded-packages/spec.js.map +1 -1
- package/dist/services/embedded-packages/url.d.ts +39 -0
- package/dist/services/embedded-packages/url.js +60 -0
- package/dist/services/embedded-packages/url.js.map +1 -0
- package/dist/services/playwright-project-bundler.js +27 -23
- package/dist/services/playwright-project-bundler.js.map +1 -1
- package/dist/services/runner/registries.d.ts +144 -0
- package/dist/services/runner/registries.js +232 -0
- package/dist/services/runner/registries.js.map +1 -0
- package/oclif.manifest.json +1 -1
- package/package.json +5 -5
|
@@ -5,12 +5,24 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import Debug from 'debug';
|
|
7
7
|
import * as uuid from 'uuid';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
8
9
|
import { checklyStorage } from '../../rest/api.js';
|
|
9
10
|
import { PayloadTooLargeError } from '../../rest/errors.js';
|
|
10
11
|
import { EMBEDDED_PACKAGES_ARCHIVE_DIR } from '../embedded-packages/materializer.js';
|
|
11
|
-
import {
|
|
12
|
+
import { filterTarballsByLockfile } from '../embedded-packages/lockfile-filter.js';
|
|
13
|
+
import { composeWorkspaceCacheHash, canonicalizePackageJson, loadWorkspaceCacheHashInputs, PACKAGE_JSON_EXCLUDED_FIELDS, } from './cache-hash.js';
|
|
14
|
+
import { lockfileArchivePath, manifestArchivePath, pruneBundledLockfile } from './lockfile-pruner.js';
|
|
15
|
+
import { normalizePackagePrune, prunePackageJson, } from './package-prune.js';
|
|
16
|
+
import { findUnrepairedPatchKeys, isRemovablePatchPath, planPatchFilter, } from './patched-dependencies.js';
|
|
17
|
+
import { REGISTRIES_ARCHIVE_PATH, serializeRegistries, validateRegistries } from '../runner/registries.js';
|
|
12
18
|
import { pathToPosix } from '../util.js';
|
|
13
19
|
const debug = Debug('checkly:cli:services:check-parser:bundler');
|
|
20
|
+
/**
|
|
21
|
+
* The files pnpm accepts `patchedDependencies` in, which are also their
|
|
22
|
+
* archive paths: both live at the workspace root, and the workspace root is
|
|
23
|
+
* the bundle's strip prefix (see {@link Bundler.createForWorkspace}).
|
|
24
|
+
*/
|
|
25
|
+
const PATCH_CONFIG_KINDS = ['pnpm-workspace.yaml', 'package.json'];
|
|
14
26
|
/**
|
|
15
27
|
* Where a file goes in the archive. A file usually lands at its own path
|
|
16
28
|
* relative to the bundle root, but a file bundled at the path of a symlink that
|
|
@@ -305,35 +317,100 @@ export class RemoteBundleArchive {
|
|
|
305
317
|
return this.#key;
|
|
306
318
|
}
|
|
307
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Maps planned tarballs to cache-hash records. yarn.lock plans carry no SRI
|
|
322
|
+
* tarball integrity (it is resolved from registry metadata only at
|
|
323
|
+
* materialization time), so their records use the lockfile's own checksum —
|
|
324
|
+
* an equally stable content pin that is known at plan time, keeping the
|
|
325
|
+
* eager placeholder hash and the finalize hash consistent. The parsers
|
|
326
|
+
* guarantee one of the two hashes is always present; the empty-string
|
|
327
|
+
* fallback only satisfies the type.
|
|
328
|
+
*/
|
|
329
|
+
export function embeddedPackageHashInputs(tarballs) {
|
|
330
|
+
return tarballs?.map(({ name, version, integrity, lockfileChecksum }) => ({
|
|
331
|
+
name,
|
|
332
|
+
version,
|
|
333
|
+
integrity: integrity ?? lockfileChecksum ?? '',
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
308
336
|
export class Bundler {
|
|
309
337
|
#id;
|
|
310
338
|
#marker;
|
|
311
|
-
#
|
|
339
|
+
#cacheHashMarker;
|
|
312
340
|
#tempDir;
|
|
313
341
|
#stripPrefix;
|
|
342
|
+
#workspaceContext;
|
|
314
343
|
#files = new Map();
|
|
344
|
+
/**
|
|
345
|
+
* Archive paths of manifests a finalize-time rewrite replaced — package
|
|
346
|
+
* pruning (`bundle.packages.prune`) or the patch filtering. Unlike a
|
|
347
|
+
* synthesized member manifest, a rewritten manifest has an on-disk original,
|
|
348
|
+
* so it is hashed the way on-disk manifests are — with `version` stripped —
|
|
349
|
+
* rather than verbatim. Hashing the raw bytes would make a release bump
|
|
350
|
+
* alone change the dependency cache key even though no install input did.
|
|
351
|
+
*/
|
|
352
|
+
#rewrittenManifests = new Set();
|
|
315
353
|
constructor(options) {
|
|
316
|
-
const { tempDir, cacheHash, stripPrefix, } = options;
|
|
354
|
+
const { tempDir, cacheHash, stripPrefix, workspaceContext, } = options;
|
|
317
355
|
this.#id = uuid.v4();
|
|
318
356
|
this.#marker = new BundlePathMarker(`bundle:${this.#id}`);
|
|
319
|
-
this.#
|
|
357
|
+
this.#cacheHashMarker = new CacheHashMarker(cacheHash);
|
|
320
358
|
this.#stripPrefix = stripPrefix;
|
|
321
359
|
this.#tempDir = tempDir;
|
|
360
|
+
this.#workspaceContext = workspaceContext;
|
|
322
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Creates a bundler without a workspace context. Workspace-dependent
|
|
364
|
+
* finalize() behavior — lockfile pruning, embedded package
|
|
365
|
+
* materialization (`bundle.packages.embed`) and the cache-hash recompute
|
|
366
|
+
* — only happens for bundlers built with {@link createForWorkspace}; a
|
|
367
|
+
* plain bundler archives exactly the files registered into it.
|
|
368
|
+
*/
|
|
323
369
|
// eslint-disable-next-line require-await
|
|
324
370
|
static async create(options) {
|
|
325
371
|
debug(`Creating bundler`);
|
|
326
372
|
return new Bundler(options);
|
|
327
373
|
}
|
|
328
|
-
static async createForWorkspace(workspace, options
|
|
374
|
+
static async createForWorkspace(workspace, options) {
|
|
329
375
|
debug(`Creating bundler for workspace`);
|
|
330
|
-
const { tempDir, dependencyCacheVersion, embeddedPackagesMaterializer, } = options;
|
|
376
|
+
const { tempDir, dependencyCacheVersion, embeddedPackagesMaterializer, packageManager, packagePrune, runnerRegistries, } = options;
|
|
331
377
|
const embeddedPackages = (await embeddedPackagesMaterializer?.plan())?.tarballs;
|
|
332
|
-
|
|
378
|
+
// Serialized once so the eager cache hash below, the finalize()
|
|
379
|
+
// recompute and the registered bundle file all agree on the exact
|
|
380
|
+
// bytes. Re-validated on entry like `normalizePackagePrune` is — the
|
|
381
|
+
// config loader has already validated config-sourced values, but
|
|
382
|
+
// programmatic callers reach this constructor directly.
|
|
383
|
+
const runnerRegistriesContent = runnerRegistries !== undefined
|
|
384
|
+
? serializeRegistries(validateRegistries(runnerRegistries))
|
|
385
|
+
: undefined;
|
|
386
|
+
const runnerRegistriesDigest = runnerRegistriesContent !== undefined
|
|
387
|
+
? createHash('sha256').update(runnerRegistriesContent, 'utf8').digest()
|
|
388
|
+
: undefined;
|
|
389
|
+
// The composition is captured so finalize() can recompute the hash with
|
|
390
|
+
// bundle-time additions (faux manifests, a pruned lockfile) from the
|
|
391
|
+
// same workspace inputs, loaded once. The eager value below is only a
|
|
392
|
+
// placeholder for the window before finalize() runs — finalize() always
|
|
393
|
+
// recomputes it.
|
|
394
|
+
const cacheHashInputs = await loadWorkspaceCacheHashInputs(workspace);
|
|
395
|
+
const composeCacheHash = (extra) => {
|
|
396
|
+
return composeWorkspaceCacheHash(cacheHashInputs, {
|
|
397
|
+
dependencyCacheVersion,
|
|
398
|
+
runnerRegistries: runnerRegistriesDigest,
|
|
399
|
+
...extra,
|
|
400
|
+
});
|
|
401
|
+
};
|
|
333
402
|
return new Bundler({
|
|
334
403
|
tempDir,
|
|
335
|
-
cacheHash,
|
|
404
|
+
cacheHash: composeCacheHash({ embeddedPackages: embeddedPackageHashInputs(embeddedPackages) }),
|
|
336
405
|
stripPrefix: workspace?.root.path,
|
|
406
|
+
workspaceContext: {
|
|
407
|
+
workspace,
|
|
408
|
+
packageManager,
|
|
409
|
+
embeddedPackagesMaterializer,
|
|
410
|
+
packagePrune: normalizePackagePrune(packagePrune),
|
|
411
|
+
runnerRegistriesContent,
|
|
412
|
+
composeCacheHash,
|
|
413
|
+
},
|
|
337
414
|
});
|
|
338
415
|
}
|
|
339
416
|
get marker() {
|
|
@@ -342,8 +419,15 @@ export class Bundler {
|
|
|
342
419
|
updateMarker(newValue) {
|
|
343
420
|
this.#marker.updateValue(newValue);
|
|
344
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* The dependency cache hash. A mutable holder rather than a plain string:
|
|
424
|
+
* consumers copy it into check payloads during bundle(), but the final
|
|
425
|
+
* value — reflecting faux manifests and a possibly pruned lockfile — is
|
|
426
|
+
* only known once finalize() has run, the same ordering problem
|
|
427
|
+
* {@link BundlePathMarker} solves for the archive path.
|
|
428
|
+
*/
|
|
345
429
|
get cacheHash() {
|
|
346
|
-
return this.#
|
|
430
|
+
return this.#cacheHashMarker;
|
|
347
431
|
}
|
|
348
432
|
/**
|
|
349
433
|
* Whether any files have been registered for bundling. Only Playwright check
|
|
@@ -369,7 +453,460 @@ export class Bundler {
|
|
|
369
453
|
this.#files.set(key, newFile);
|
|
370
454
|
}
|
|
371
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Prunes the bundled lockfile to match the bundle's final file set,
|
|
458
|
+
* materializes the embedded package tarballs the (possibly pruned)
|
|
459
|
+
* lockfile still references, and recomputes the cache hash from the
|
|
460
|
+
* bundle's actual install inputs (faux manifests, the pruned lockfile and
|
|
461
|
+
* the shipped embedded set). Runs at finalize time because all of it
|
|
462
|
+
* depends on the complete file set, which only exists once every check
|
|
463
|
+
* has registered its files — and because materializing after pruning is
|
|
464
|
+
* what keeps pruned-away tarballs from ever being downloaded.
|
|
465
|
+
*/
|
|
466
|
+
async #refreshWorkspaceBundle() {
|
|
467
|
+
const context = this.#workspaceContext;
|
|
468
|
+
if (context === undefined) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const prunedManifestOriginals = await this.#pruneManifestPackages(context);
|
|
472
|
+
const lockfilePrune = await this.#pruneLockfile(context);
|
|
473
|
+
const pruned = await this.#dropUnusedPatches(context, lockfilePrune.pruned);
|
|
474
|
+
// Pruned manifests must never ship alongside an unpruned lockfile: the
|
|
475
|
+
// lockfile's importer sections would declare dependencies the shipped
|
|
476
|
+
// manifests lack, failing the runner's frozen install. When lockfile
|
|
477
|
+
// pruning skipped or failed for any reason, roll the manifest rewrite
|
|
478
|
+
// back — unless the skip itself proved the original lockfile already
|
|
479
|
+
// matches the pruned manifests (a byte-identical regeneration), or the
|
|
480
|
+
// bundle ships no lockfile at all, in which case the manifests are the
|
|
481
|
+
// install's only input and nothing can fall out of sync.
|
|
482
|
+
if (pruned === undefined
|
|
483
|
+
&& !lockfilePrune.consistent
|
|
484
|
+
&& prunedManifestOriginals.size > 0
|
|
485
|
+
&& this.#bundledLockfilePath(context) !== undefined) {
|
|
486
|
+
for (const [manifestPath, original] of prunedManifestOriginals) {
|
|
487
|
+
this.#files.set(manifestPath, original);
|
|
488
|
+
this.#rewrittenManifests.delete(manifestPath);
|
|
489
|
+
}
|
|
490
|
+
process.stderr.write(`Warning: bundle.packages.prune was not applied because the bundled lockfile could `
|
|
491
|
+
+ `not be pruned to match; the original manifests ship unchanged.\n`);
|
|
492
|
+
}
|
|
493
|
+
const embeddedPackages = await this.#materializeEmbeddedPackages(context, pruned);
|
|
494
|
+
this.#registerRunnerRegistries(context);
|
|
495
|
+
const fauxPackageJsons = [];
|
|
496
|
+
for (const [archivePath, file] of this.#files) {
|
|
497
|
+
if (file.physical || path.posix.basename(archivePath) !== 'package.json') {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const raw = Buffer.from(file.content, 'utf8');
|
|
501
|
+
fauxPackageJsons.push({
|
|
502
|
+
path: archivePath,
|
|
503
|
+
raw: this.#rewrittenManifests.has(archivePath)
|
|
504
|
+
? canonicalizePackageJson(raw, PACKAGE_JSON_EXCLUDED_FIELDS)
|
|
505
|
+
: raw,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
// Unconditional: with no faux manifests, no pruned lockfile and an
|
|
509
|
+
// unfiltered embedded set this reproduces the exact digest computed in
|
|
510
|
+
// createForWorkspace (empty record groups write nothing).
|
|
511
|
+
this.#cacheHashMarker.updateValue(context.composeCacheHash({
|
|
512
|
+
fauxPackageJsons,
|
|
513
|
+
prunedLockfile: pruned,
|
|
514
|
+
embeddedPackages: embeddedPackageHashInputs(embeddedPackages),
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Registers the serialized `runner.registries` configuration into the
|
|
519
|
+
* bundle at {@link REGISTRIES_ARCHIVE_PATH}. The path is CLI-managed:
|
|
520
|
+
* runners honor whatever lands there, so a pre-existing entry — a
|
|
521
|
+
* hand-written file an `include` glob dragged in, which has passed no
|
|
522
|
+
* validation and contributes nothing to the cache hash — is dropped
|
|
523
|
+
* rather than shipped, whether or not the option is set. Set directly
|
|
524
|
+
* rather than through `registerFiles`, whose prefer-physical rule
|
|
525
|
+
* would keep such a file. Skipped when the bundle is empty: the
|
|
526
|
+
* project then has no Playwright Check Suite, nothing is uploaded, and
|
|
527
|
+
* registering the file would make `isEmpty` misreport.
|
|
528
|
+
*/
|
|
529
|
+
#registerRunnerRegistries(context) {
|
|
530
|
+
if (this.#files.delete(REGISTRIES_ARCHIVE_PATH)) {
|
|
531
|
+
process.stderr.write(`Warning: ${REGISTRIES_ARCHIVE_PATH} is generated from the 'runner.registries' config `
|
|
532
|
+
+ `field and cannot be supplied as a project file; the included file was dropped.\n`);
|
|
533
|
+
}
|
|
534
|
+
const content = context.runnerRegistriesContent;
|
|
535
|
+
if (content === undefined || this.isEmpty) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
this.#files.set(REGISTRIES_ARCHIVE_PATH, {
|
|
539
|
+
// A virtual file's archive path is derived from its filePath
|
|
540
|
+
// relative to the strip prefix, so the filePath must sit under it.
|
|
541
|
+
filePath: path.join(this.#stripPrefix ?? '', REGISTRIES_ARCHIVE_PATH),
|
|
542
|
+
physical: false,
|
|
543
|
+
content,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Applies `bundle.packages.prune` to the workspace manifests in the
|
|
548
|
+
* bundle — the root's and every bundled member's — replacing each
|
|
549
|
+
* affected entry with a rewritten virtual copy. Runs before lockfile
|
|
550
|
+
* pruning so the lockfile-only install resolves against the reduced
|
|
551
|
+
* manifests and the removed dependencies fall out of the pruned lockfile.
|
|
552
|
+
* Only manifests at the workspace's own locations are touched: a
|
|
553
|
+
* package.json an `include` glob dragged in is not an install input.
|
|
554
|
+
*
|
|
555
|
+
* Returns the replaced original entries, keyed by archive path, so
|
|
556
|
+
* {@link Bundler.refreshWorkspaceBundle} can roll the rewrite back when
|
|
557
|
+
* the bundled lockfile cannot be pruned to match.
|
|
558
|
+
*/
|
|
559
|
+
async #pruneManifestPackages(context) {
|
|
560
|
+
const replaced = new Map();
|
|
561
|
+
const prune = context.packagePrune;
|
|
562
|
+
if (prune === undefined) {
|
|
563
|
+
return replaced;
|
|
564
|
+
}
|
|
565
|
+
const { workspace } = context;
|
|
566
|
+
for (const pkg of [workspace.root, ...workspace.packages]) {
|
|
567
|
+
const manifestPath = manifestArchivePath(workspace, pkg);
|
|
568
|
+
const file = this.#files.get(manifestPath);
|
|
569
|
+
if (file === undefined) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!file.physical) {
|
|
573
|
+
// The only virtual member manifests are synthesized ones (faux
|
|
574
|
+
// shims), which carry no dependency classes — and only a rewrite
|
|
575
|
+
// of a physical original may join #rewrittenManifests, whose
|
|
576
|
+
// consumers rely on the rewritten content being the real manifest.
|
|
577
|
+
debug(`Not pruning ${manifestPath}: not a physical manifest`);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (file.symlinkTarget !== undefined) {
|
|
581
|
+
this.#warnManifestPruneFailure(manifestPath, `the manifest is bundled as a symlink`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
// A virtual entry's archive name is always derived from its filePath,
|
|
585
|
+
// so a replacement can only stand in for an entry that already
|
|
586
|
+
// archives at that derived path (same rule as #readPatchConfigs).
|
|
587
|
+
const replacementArchivePath = pathToPosix(path.relative(this.#stripPrefix ?? '', file.filePath));
|
|
588
|
+
if (replacementArchivePath !== manifestPath) {
|
|
589
|
+
this.#warnManifestPruneFailure(manifestPath, `the rewritten manifest would move to ${replacementArchivePath}`);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
let content;
|
|
593
|
+
try {
|
|
594
|
+
content = await fs.readFile(file.filePath, 'utf8');
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
this.#warnManifestPruneFailure(manifestPath, `the bundled manifest could not be read (${err})`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const result = prunePackageJson(content, prune);
|
|
601
|
+
if (result === undefined) {
|
|
602
|
+
this.#warnManifestPruneFailure(manifestPath, `the manifest could not be rewritten safely`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (!result.changed) {
|
|
606
|
+
// Left physical: the entry hashes and ships exactly as before, and
|
|
607
|
+
// an untouched manifest must not force lockfile pruning.
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
replaced.set(manifestPath, file);
|
|
611
|
+
this.#files.set(manifestPath, {
|
|
612
|
+
// The archive name is derived from filePath, so the rewritten entry
|
|
613
|
+
// has to keep the original's.
|
|
614
|
+
filePath: file.filePath,
|
|
615
|
+
physical: false,
|
|
616
|
+
content: result.content,
|
|
617
|
+
});
|
|
618
|
+
this.#rewrittenManifests.add(manifestPath);
|
|
619
|
+
debug(`Pruned ${manifestPath}: ${result.removed.join(', ') || '(structural cleanup only)'}`);
|
|
620
|
+
}
|
|
621
|
+
return replaced;
|
|
622
|
+
}
|
|
623
|
+
#warnManifestPruneFailure(manifestPath, reason) {
|
|
624
|
+
// A stderr warning rather than a debug line: pruning is a user-requested
|
|
625
|
+
// transformation, and silently shipping the original manifest
|
|
626
|
+
// reintroduces the exact lockfile bloat the user configured it to
|
|
627
|
+
// remove.
|
|
628
|
+
process.stderr.write(`Warning: could not apply bundle.packages.prune to ${manifestPath}: ${reason}; `
|
|
629
|
+
+ `its original contents ship unchanged.\n`);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The archive path of the workspace's lockfile when the bundle ships it,
|
|
633
|
+
* mirroring the checks lockfile pruning performs before running.
|
|
634
|
+
*/
|
|
635
|
+
#bundledLockfilePath(context) {
|
|
636
|
+
const archivePath = lockfileArchivePath(context.workspace);
|
|
637
|
+
if (archivePath === undefined || !this.#files.has(archivePath)) {
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
return archivePath;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Materializes the embedded package tarballs into the bundle: the full
|
|
644
|
+
* planned set, or — when the bundled lockfile was pruned — only the
|
|
645
|
+
* tarballs the pruned lockfile still references, so pruned-away packages
|
|
646
|
+
* are never downloaded. Returns the shipped set for the cache hash.
|
|
647
|
+
*/
|
|
648
|
+
async #materializeEmbeddedPackages(context, pruned) {
|
|
649
|
+
const materializer = context.embeddedPackagesMaterializer;
|
|
650
|
+
if (materializer === undefined) {
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
// plan() is memoized and was already awaited in createForWorkspace, so
|
|
654
|
+
// this resolves the same promise without extra work.
|
|
655
|
+
const { tarballs: embeddedPackages } = await materializer.plan();
|
|
656
|
+
// The empty-bundle arm is load-bearing: every command calls finalize()
|
|
657
|
+
// unconditionally, including on bundles no check registered files into,
|
|
658
|
+
// and an empty bundle must not trigger downloads (nor the materializer's
|
|
659
|
+
// plan-issues backstop, which validation never ran for a project
|
|
660
|
+
// without Playwright checks). Nothing ships from an empty bundle, so
|
|
661
|
+
// nothing reaches the hash either.
|
|
662
|
+
if (this.isEmpty) {
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
let kept = embeddedPackages;
|
|
666
|
+
if (pruned !== undefined) {
|
|
667
|
+
const filtered = filterTarballsByLockfile(embeddedPackages, pruned.content, pruned.name);
|
|
668
|
+
kept = filtered.kept;
|
|
669
|
+
if (filtered.dropped.length > 0) {
|
|
670
|
+
debug(`Embedded packages dropped with the pruned lockfile: ${filtered.dropped.map(tarball => `${tarball.name}@${tarball.version}`).join(', ')}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Debug rather than user-facing output for now: a bare stderr line has
|
|
674
|
+
// no good home in the current CLI output UX. Revisit when finalize-time
|
|
675
|
+
// work gets proper progress reporting.
|
|
676
|
+
if (kept.length > 0) {
|
|
677
|
+
debug(`Preparing ${kept.length} embedded package tarball(s)`);
|
|
678
|
+
}
|
|
679
|
+
// Deliberately called even for an empty kept set: the materializer's
|
|
680
|
+
// plan-issues backstop must still reject a plan whose specs all failed
|
|
681
|
+
// to resolve.
|
|
682
|
+
const materialized = await materializer.materializeTarballs(kept);
|
|
683
|
+
this.registerFiles(...materialized.map(tarball => ({
|
|
684
|
+
filePath: tarball.filePath,
|
|
685
|
+
physical: true,
|
|
686
|
+
archivePath: tarball.archivePath,
|
|
687
|
+
})));
|
|
688
|
+
return kept;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Filters the bundle's pnpm patch declarations down to the ones the pruned
|
|
692
|
+
* lockfile shows still apply, dropping the matching patch files and lockfile
|
|
693
|
+
* entries with them.
|
|
694
|
+
*
|
|
695
|
+
* A bundle carries the workspace's whole `patchedDependencies` map but only
|
|
696
|
+
* a subset of its members, so a patch whose package belongs to an unbundled
|
|
697
|
+
* member ends up applying to nothing. pnpm rejects that outright when it
|
|
698
|
+
* re-resolves, which is why the prune install tolerates it (see
|
|
699
|
+
* PNpmDetector.lockfileOnlyInstallCommand) and the leftovers are cleaned up
|
|
700
|
+
* here instead — the pruned lockfile is the first point at which the bundle's
|
|
701
|
+
* real dependency graph is known.
|
|
702
|
+
*
|
|
703
|
+
* Every failure mode leaves the bundle exactly as pruning produced it, which
|
|
704
|
+
* installs correctly today; the reporting below covers the case where that
|
|
705
|
+
* fallback is nonetheless a bundle the runner would reject.
|
|
706
|
+
*/
|
|
707
|
+
async #dropUnusedPatches(context, pruned) {
|
|
708
|
+
if (pruned === undefined || context.packageManager.name !== 'pnpm') {
|
|
709
|
+
return pruned;
|
|
710
|
+
}
|
|
711
|
+
let configs = [];
|
|
712
|
+
let result = pruned;
|
|
713
|
+
try {
|
|
714
|
+
// `replaceable` is about whether the filtering may edit these files;
|
|
715
|
+
// whatever could be READ still feeds the diagnostic below, so a bundle
|
|
716
|
+
// this step declines to touch is not also a bundle it stays quiet about.
|
|
717
|
+
const read = await this.#readPatchConfigs();
|
|
718
|
+
configs = read.configs;
|
|
719
|
+
if (read.replaceable) {
|
|
720
|
+
const plan = planPatchFilter({
|
|
721
|
+
configs,
|
|
722
|
+
originalLockfileContent: pruned.originalContent,
|
|
723
|
+
prunedLockfileContent: pruned.content,
|
|
724
|
+
});
|
|
725
|
+
if (plan !== undefined) {
|
|
726
|
+
const applied = this.#applyPatchFilter(context, pruned, plan);
|
|
727
|
+
if (applied !== undefined) {
|
|
728
|
+
result = applied;
|
|
729
|
+
// The diagnostic below must see what actually ships, so the config
|
|
730
|
+
// is swapped for its rewritten bytes only once the apply took.
|
|
731
|
+
configs = configs.map(config => config.archivePath === plan.rewrittenConfig.archivePath
|
|
732
|
+
? { ...config, content: plan.rewrittenConfig.content }
|
|
733
|
+
: config);
|
|
734
|
+
debug(`Dropped unused patch declarations: ${plan.unusedKeys.join(', ')}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
debug(`Could not filter the bundle's patch declarations: ${err}`);
|
|
741
|
+
}
|
|
742
|
+
// Evaluated on every path, including the ones that changed nothing: a
|
|
743
|
+
// config declaring a patch the shipped lockfile does not record is what
|
|
744
|
+
// makes the runner's install fail, and silently shipping that is the
|
|
745
|
+
// failure this step exists to prevent.
|
|
746
|
+
if (configs.length > 0) {
|
|
747
|
+
const unrepaired = findUnrepairedPatchKeys({
|
|
748
|
+
configs,
|
|
749
|
+
originalLockfileContent: pruned.originalContent,
|
|
750
|
+
shippedLockfileContent: result.content,
|
|
751
|
+
});
|
|
752
|
+
if (unrepaired.length > 0) {
|
|
753
|
+
process.stderr.write(`Note: the bundled pnpm config declares patches that the bundled lockfile does not `
|
|
754
|
+
+ `record (${unrepaired.join(', ')}), which can fail the remote install. Your own lockfile `
|
|
755
|
+
+ `does record them, so run your package manager's install to refresh it rather than `
|
|
756
|
+
+ `removing the entries; set CHECKLY_LOCKFILE_PRUNE=0 to opt out of pruning entirely.\n`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Reads the bundle's copies of the two files pnpm accepts
|
|
763
|
+
* `patchedDependencies` in.
|
|
764
|
+
*
|
|
765
|
+
* `replaceable` is false when a candidate exists but cannot be read or
|
|
766
|
+
* cannot be swapped for a rewritten copy; filtering one declaring config
|
|
767
|
+
* while leaving the other would manufacture the very config/lockfile
|
|
768
|
+
* mismatch this step removes. Whatever *could* be read is still returned, so
|
|
769
|
+
* the caller can report on a bundle it declines to edit.
|
|
770
|
+
*/
|
|
771
|
+
async #readPatchConfigs() {
|
|
772
|
+
const configs = [];
|
|
773
|
+
let replaceable = true;
|
|
774
|
+
// The archive path and the kind are the same string here; see
|
|
775
|
+
// PATCH_CONFIG_KINDS for why.
|
|
776
|
+
for (const archivePath of PATCH_CONFIG_KINDS) {
|
|
777
|
+
const file = this.#files.get(archivePath);
|
|
778
|
+
if (file === undefined) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
// A virtual entry's archive name is always derived from its filePath,
|
|
782
|
+
// so a replacement can only stand in for an entry that already archives
|
|
783
|
+
// at that derived path. An entry bundled somewhere other than its own
|
|
784
|
+
// path — reached through a symlink, and carrying an explicit
|
|
785
|
+
// archivePath — cannot be replaced without moving it.
|
|
786
|
+
const replacementArchivePath = pathToPosix(path.relative(this.#stripPrefix ?? '', file.filePath));
|
|
787
|
+
if (replacementArchivePath !== archivePath) {
|
|
788
|
+
debug(`Bundled ${archivePath} would move to ${replacementArchivePath} if rewritten;`
|
|
789
|
+
+ ` leaving patches alone`);
|
|
790
|
+
replaceable = false;
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
const content = file.physical
|
|
794
|
+
? await fs.readFile(file.filePath, 'utf8')
|
|
795
|
+
: file.content;
|
|
796
|
+
configs.push({ archivePath, kind: archivePath, content });
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
debug(`Could not read bundled ${archivePath}: ${err}`);
|
|
800
|
+
replaceable = false;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return { configs, replaceable };
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Applies a patch filter plan. Deliberately pure map mutation over
|
|
807
|
+
* already-computed content: a throw partway through would ship a bundle
|
|
808
|
+
* whose config, patch files and lockfile disagree, which fails the runner's
|
|
809
|
+
* install either way it lands.
|
|
810
|
+
*/
|
|
811
|
+
#applyPatchFilter(context, pruned, plan) {
|
|
812
|
+
// Resolved before anything is mutated: the config entry is what the plan
|
|
813
|
+
// was computed from, and rewriting the lockfile without it would ship the
|
|
814
|
+
// mismatch this step exists to remove.
|
|
815
|
+
const existing = this.#files.get(plan.rewrittenConfig.archivePath);
|
|
816
|
+
if (existing === undefined) {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
this.#files.set(plan.rewrittenConfig.archivePath, {
|
|
820
|
+
// The archive name is derived from filePath, so the rewritten entry has
|
|
821
|
+
// to keep the original's.
|
|
822
|
+
filePath: existing.filePath,
|
|
823
|
+
physical: false,
|
|
824
|
+
content: plan.rewrittenConfig.content,
|
|
825
|
+
});
|
|
826
|
+
if (path.posix.basename(plan.rewrittenConfig.archivePath) === 'package.json') {
|
|
827
|
+
this.#rewrittenManifests.add(plan.rewrittenConfig.archivePath);
|
|
828
|
+
}
|
|
829
|
+
for (const patchPath of plan.droppedPatchPaths) {
|
|
830
|
+
// Defense in depth, as an allow-list rather than a list of things not to
|
|
831
|
+
// delete: a declared patch path is user input reaching a delete, and it
|
|
832
|
+
// can name any bundled file — an .npmrc carrying registry auth, a
|
|
833
|
+
// pnpmfile the lockfile records a checksum for, or a `.patch` fixture
|
|
834
|
+
// that a check's own `include` glob put in the bundle. Only the
|
|
835
|
+
// conventional location the auto-include adds patches from is removable;
|
|
836
|
+
// a patch kept elsewhere simply stays, inert, exactly as an
|
|
837
|
+
// unreferenced one does.
|
|
838
|
+
if (!isRemovablePatchPath(patchPath)) {
|
|
839
|
+
debug(`Refusing to drop ${patchPath}: outside the conventional patches directory`);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
this.#files.delete(patchPath);
|
|
843
|
+
}
|
|
844
|
+
this.#files.set(pruned.archivePath, {
|
|
845
|
+
filePath: context.workspace.lockfile.unwrap(),
|
|
846
|
+
physical: false,
|
|
847
|
+
content: plan.lockfileContent,
|
|
848
|
+
});
|
|
849
|
+
return {
|
|
850
|
+
...pruned,
|
|
851
|
+
content: plan.lockfileContent,
|
|
852
|
+
hash: createHash('sha256').update(plan.lockfileContent).digest(),
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
async #pruneLockfile(context) {
|
|
856
|
+
const result = await pruneBundledLockfile({
|
|
857
|
+
workspace: context.workspace,
|
|
858
|
+
packageManager: context.packageManager,
|
|
859
|
+
files: this.#files,
|
|
860
|
+
rewrittenManifests: this.#rewrittenManifests,
|
|
861
|
+
});
|
|
862
|
+
if (result.status === 'failed') {
|
|
863
|
+
process.stderr.write(`Warning: could not prune the bundled lockfile: ${result.reason}. `
|
|
864
|
+
+ `Falling back to the original lockfile; it may reference workspace packages and `
|
|
865
|
+
+ `dependencies that are not part of the bundle. If the lockfile is out of date, `
|
|
866
|
+
+ `run your package manager's install to refresh it; set CHECKLY_LOCKFILE_PRUNE=0 `
|
|
867
|
+
+ `to disable pruning.\n`);
|
|
868
|
+
return {};
|
|
869
|
+
}
|
|
870
|
+
if (result.status === 'skipped') {
|
|
871
|
+
if (result.notable) {
|
|
872
|
+
// The bundle is a partial workspace, so the unpruned lockfile
|
|
873
|
+
// over-describes it — say so instead of failing silently on the
|
|
874
|
+
// remote install.
|
|
875
|
+
process.stderr.write(`Note: the bundled lockfile was not pruned (${result.reason}); it may reference `
|
|
876
|
+
+ `workspace packages and dependencies that are not part of the bundle. If this `
|
|
877
|
+
+ `setup cannot be pruned, set CHECKLY_LOCKFILE_PRUNE=0 to opt out of pruning `
|
|
878
|
+
+ `(and this note) entirely.\n`);
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
debug(`Lockfile pruning skipped: ${result.reason}`);
|
|
882
|
+
}
|
|
883
|
+
return { consistent: result.consistent };
|
|
884
|
+
}
|
|
885
|
+
// Set entries directly rather than through registerFiles: its
|
|
886
|
+
// prefer-physical dedup would keep the original lockfile, and would drop
|
|
887
|
+
// a backfilled manifest whose path is occupied by a symlink entry —
|
|
888
|
+
// desyncing the bundle from the lockfile the prune was computed against.
|
|
889
|
+
for (const manifest of result.backfilledManifests) {
|
|
890
|
+
this.#files.set(archivePath(manifest, this.#stripPrefix), manifest);
|
|
891
|
+
}
|
|
892
|
+
this.#files.set(result.archivePath, {
|
|
893
|
+
filePath: context.workspace.lockfile.unwrap(),
|
|
894
|
+
physical: false,
|
|
895
|
+
content: result.content,
|
|
896
|
+
});
|
|
897
|
+
debug(`Pruned bundled lockfile ${result.archivePath}`);
|
|
898
|
+
return {
|
|
899
|
+
pruned: {
|
|
900
|
+
name: path.posix.basename(result.archivePath),
|
|
901
|
+
hash: createHash('sha256').update(result.content).digest(),
|
|
902
|
+
content: result.content,
|
|
903
|
+
archivePath: result.archivePath,
|
|
904
|
+
originalContent: result.originalContent,
|
|
905
|
+
},
|
|
906
|
+
};
|
|
907
|
+
}
|
|
372
908
|
async finalize() {
|
|
909
|
+
await this.#refreshWorkspaceBundle();
|
|
373
910
|
const archive = await BundleArchive.create({
|
|
374
911
|
tempDir: this.#tempDir,
|
|
375
912
|
stripPrefix: this.#stripPrefix,
|
|
@@ -407,4 +944,25 @@ export class BundlePathMarker {
|
|
|
407
944
|
return this.#value;
|
|
408
945
|
}
|
|
409
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* Mutable holder for the dependency cache hash, serialized as a plain
|
|
949
|
+
* string. See {@link Bundler.cacheHash} for why a holder is needed.
|
|
950
|
+
*
|
|
951
|
+
* Deliberately a standalone class rather than a subclass of
|
|
952
|
+
* {@link BundlePathMarker}: the own `#value` field makes the two marker
|
|
953
|
+
* types nominally incompatible, so a bundle path cannot be passed where the
|
|
954
|
+
* cache hash is expected (or vice versa) without a compile error.
|
|
955
|
+
*/
|
|
956
|
+
export class CacheHashMarker {
|
|
957
|
+
#value;
|
|
958
|
+
constructor(initialValue) {
|
|
959
|
+
this.#value = initialValue;
|
|
960
|
+
}
|
|
961
|
+
updateValue(newValue) {
|
|
962
|
+
this.#value = newValue;
|
|
963
|
+
}
|
|
964
|
+
toJSON() {
|
|
965
|
+
return this.#value;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
410
968
|
//# sourceMappingURL=bundler.js.map
|