checkly 8.19.0 → 8.20.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/context.d.ts +0 -6
- package/dist/ai-context/context.js +0 -9
- package/dist/ai-context/context.js.map +1 -1
- package/dist/ai-context/public-skills/checkly/SKILL.md +2 -2
- package/dist/ai-context/skills-command/references/configure-playwright-checks.md +1 -0
- package/dist/ai-context/skills-command/references/configure.md +1 -4
- package/dist/ai-context/skills-command/references/initialize.md +1 -1
- package/dist/ai-context/skills-command/references/investigate-test-sessions.md +1 -1
- package/dist/ai-context/skills-command/references/manage-plan.md +1 -1
- package/dist/ai-context/skills-command/references/manage.md +3 -3
- package/dist/commands/account/plan.d.ts +13 -0
- package/dist/commands/account/plan.js +3 -2
- package/dist/commands/account/plan.js.map +1 -1
- package/dist/commands/debug/parse-project.js +3 -1
- package/dist/commands/debug/parse-project.js.map +1 -1
- package/dist/commands/deploy.js +3 -1
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/pw-test.js +3 -1
- package/dist/commands/pw-test.js.map +1 -1
- package/dist/commands/test.js +3 -1
- package/dist/commands/test.js.map +1 -1
- package/dist/formatters/account-plan.d.ts +2 -2
- package/dist/formatters/account-plan.js +6 -6
- package/dist/formatters/account-plan.js.map +1 -1
- package/dist/rest/account-members.d.ts +3 -3
- package/dist/rest/accounts.d.ts +2 -2
- package/dist/rest/api.d.ts +1 -1
- package/dist/rest/batch-analytics.d.ts +1 -1
- package/dist/rest/cancel.d.ts +6 -2
- package/dist/rest/check-groups.d.ts +1 -1
- package/dist/rest/check-results.d.ts +2 -2
- package/dist/rest/check-statuses.d.ts +2 -2
- package/dist/rest/checkly-storage.d.ts +5 -3
- package/dist/rest/checks.d.ts +3 -3
- package/dist/rest/entitlements.d.ts +1 -1
- package/dist/rest/environment-variables.d.ts +14 -5
- package/dist/rest/error-groups.d.ts +2 -2
- package/dist/rest/heartbeat-checks.d.ts +1 -1
- package/dist/rest/locations.d.ts +1 -1
- package/dist/rest/private-locations.d.ts +1 -1
- package/dist/rest/projects.d.ts +11 -11
- package/dist/rest/rca.d.ts +3 -3
- package/dist/rest/test-session-error-groups.d.ts +1 -1
- package/dist/rest/test-sessions.d.ts +9 -9
- package/dist/rest/users.d.ts +1 -1
- package/dist/services/check-parser/bundler.d.ts +2 -1
- package/dist/services/check-parser/bundler.js +112 -17
- package/dist/services/check-parser/bundler.js.map +1 -1
- package/dist/services/check-parser/cache-hash.d.ts +29 -1
- package/dist/services/check-parser/cache-hash.js +30 -1
- package/dist/services/check-parser/cache-hash.js.map +1 -1
- package/dist/services/check-parser/parser.d.ts +18 -0
- package/dist/services/check-parser/parser.js +9 -2
- package/dist/services/check-parser/parser.js.map +1 -1
- package/dist/services/check-parser/playwright-config-expander.d.ts +10 -1
- package/dist/services/check-parser/playwright-config-expander.js +87 -8
- package/dist/services/check-parser/playwright-config-expander.js.map +1 -1
- package/dist/services/checkly-config-loader.d.ts +28 -0
- package/dist/services/checkly-config-loader.js +10 -0
- package/dist/services/checkly-config-loader.js.map +1 -1
- package/dist/services/playwright-config.d.ts +14 -0
- package/dist/services/playwright-config.js +62 -9
- package/dist/services/playwright-config.js.map +1 -1
- package/dist/services/playwright-project-bundler.js +42 -5
- package/dist/services/playwright-project-bundler.js.map +1 -1
- package/dist/services/symlink-resolver.d.ts +56 -0
- package/dist/services/symlink-resolver.js +860 -0
- package/dist/services/symlink-resolver.js.map +1 -0
- package/oclif.manifest.json +447 -447
- package/package.json +14 -14
- package/dist/ai-context/skills-command/references/configure-agentic-checks.md +0 -65
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Debug from 'debug';
|
|
4
|
+
import { glob } from 'glob';
|
|
5
|
+
import { minimatch } from 'minimatch';
|
|
6
|
+
import { pathToPosix } from './util.js';
|
|
7
|
+
const debug = Debug('checkly:cli:services:symlink-resolver');
|
|
8
|
+
const NODE_MODULES = 'node_modules';
|
|
9
|
+
const PNPM_STORE = '.pnpm';
|
|
10
|
+
const BIN_DIR = '.bin';
|
|
11
|
+
/**
|
|
12
|
+
* pnpm records the state of a node_modules directory in these files. They must
|
|
13
|
+
* never end up in a code bundle: on the runner the store directory differs from
|
|
14
|
+
* the one they name, and pnpm reacts by purging node_modules entirely — without
|
|
15
|
+
* prompting when CI is set.
|
|
16
|
+
*/
|
|
17
|
+
const PACKAGE_MANAGER_STATE_FILES = new Set([
|
|
18
|
+
'.modules.yaml',
|
|
19
|
+
'.pnpm-workspace-state.json',
|
|
20
|
+
'.pnpm-workspace-state-v1.json',
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Turns the paths matched by the include globs into archive entries, resolving
|
|
24
|
+
* symlinks so that the resulting archive is both extractable and usable.
|
|
25
|
+
*
|
|
26
|
+
* Two problems make this necessary.
|
|
27
|
+
*
|
|
28
|
+
* Extractability: glob's `nodir` option filters on lstat, so a symlink pointing
|
|
29
|
+
* at a directory is reported as if it were a regular file, while glob separately
|
|
30
|
+
* walks *through* that same symlink and reports the files beneath it. Archiving
|
|
31
|
+
* both yields a path that is simultaneously a symlink and a directory, which tar
|
|
32
|
+
* refuses to extract. Package managers that link packages out of a shared store
|
|
33
|
+
* (pnpm) make that the ordinary shape of node_modules.
|
|
34
|
+
*
|
|
35
|
+
* Usability: simply dereferencing the symlink does not work either. Under pnpm a
|
|
36
|
+
* package's dependencies are siblings of it inside the store, not children, so a
|
|
37
|
+
* flattened copy of node_modules/<pkg> cannot resolve anything it depends on.
|
|
38
|
+
*
|
|
39
|
+
* The archive therefore keeps symlinks as symlinks and brings their targets
|
|
40
|
+
* along, which reproduces the layout the package manager built. The invariant
|
|
41
|
+
* that makes it extractable: an entry is either a symlink, which by construction
|
|
42
|
+
* never has children, or a regular file at a symlink-free archive path.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveBundleFiles(options) {
|
|
45
|
+
const resolver = new SymlinkResolver(options);
|
|
46
|
+
return await resolver.resolve(options.matchedPaths, options.referencedPaths ?? [], options.workspaceMembers ?? []);
|
|
47
|
+
}
|
|
48
|
+
class SymlinkResolver {
|
|
49
|
+
/**
|
|
50
|
+
* Candidate archive roots. A path may be expressed either lexically (as the
|
|
51
|
+
* include globs produced it) or canonically (as realpath produced it), and on
|
|
52
|
+
* macOS those differ whenever the project sits under a symlinked prefix such
|
|
53
|
+
* as /tmp. Both spellings must map to the same archive path.
|
|
54
|
+
*/
|
|
55
|
+
#roots = [];
|
|
56
|
+
#ignoreCwd;
|
|
57
|
+
#ignorePatterns;
|
|
58
|
+
/** Archive entries, keyed by archive path. */
|
|
59
|
+
#entries = new Map();
|
|
60
|
+
/** Paths already classified. */
|
|
61
|
+
#classified = new Set();
|
|
62
|
+
/** Paths the include globs matched outright, as opposed to ones we followed to. */
|
|
63
|
+
#directPaths = new Set();
|
|
64
|
+
/** Real directories already expanded. */
|
|
65
|
+
#expanded = new Set();
|
|
66
|
+
/**
|
|
67
|
+
* Store directories whose dependency links have been collected. Without this,
|
|
68
|
+
* a dependency cycle — which pnpm stores have whenever two packages depend on
|
|
69
|
+
* each other, as they routinely do — would recurse until the process dies, and
|
|
70
|
+
* even an acyclic graph would be walked once per distinct path through it.
|
|
71
|
+
*/
|
|
72
|
+
#closed = new Set();
|
|
73
|
+
/** Workspace member package names, keyed by canonical member directory. */
|
|
74
|
+
#memberNames = new Map();
|
|
75
|
+
/** Out-of-root directories already copied, and where each one landed. */
|
|
76
|
+
#copiedTrees = new Map();
|
|
77
|
+
#lstatCache = new Map();
|
|
78
|
+
#warned = new Set();
|
|
79
|
+
constructor(options) {
|
|
80
|
+
this.#ignoreCwd = options.ignoreCwd;
|
|
81
|
+
this.#ignorePatterns = options.ignorePatterns;
|
|
82
|
+
this.#roots = [options.bundleRoot];
|
|
83
|
+
}
|
|
84
|
+
async resolve(matchedPaths, referencedPaths, workspaceMembers) {
|
|
85
|
+
const [bundleRoot] = this.#roots;
|
|
86
|
+
// The canonical root is what real paths are measured against; the lexical
|
|
87
|
+
// root is what the include globs produced. Keep both, or a project reached
|
|
88
|
+
// through a symlink would treat every real path as being outside the root.
|
|
89
|
+
try {
|
|
90
|
+
const realRoot = await fs.realpath(bundleRoot);
|
|
91
|
+
if (realRoot !== bundleRoot) {
|
|
92
|
+
this.#roots.push(realRoot);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Root does not exist; nothing can be inside it anyway.
|
|
97
|
+
}
|
|
98
|
+
// Member paths are stored canonically: the workspace model records them as
|
|
99
|
+
// given (sometimes lexical), while the link targets they are compared with
|
|
100
|
+
// arrive here as realpaths.
|
|
101
|
+
for (const member of workspaceMembers) {
|
|
102
|
+
this.#memberNames.set(await this.#realpath(member.path) ?? member.path, member.name);
|
|
103
|
+
}
|
|
104
|
+
// Which paths the include globs matched is a property of the path, not of
|
|
105
|
+
// when it happens to be reached: expansion can arrive at a directly-matched
|
|
106
|
+
// file first, and it must not then be judged by rules the glob already
|
|
107
|
+
// applied to it.
|
|
108
|
+
for (const matchedPath of matchedPaths) {
|
|
109
|
+
this.#directPaths.add(matchedPath);
|
|
110
|
+
}
|
|
111
|
+
for (const matchedPath of matchedPaths) {
|
|
112
|
+
await this.#classify(matchedPath);
|
|
113
|
+
}
|
|
114
|
+
for (const referencedPath of referencedPaths) {
|
|
115
|
+
await this.#carryReferencedLinks(referencedPath);
|
|
116
|
+
}
|
|
117
|
+
this.#pruneSymlinks();
|
|
118
|
+
return Array.from(this.#entries.values());
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Emits a symlink entry for every link a referenced path traverses, so the
|
|
122
|
+
* path resolves in the extracted archive exactly as spelled. Content is not
|
|
123
|
+
* this method's concern — whoever referenced the path also discovers and
|
|
124
|
+
* bundles what it points at (at real paths). Only the links travel here.
|
|
125
|
+
*
|
|
126
|
+
* The walk mirrors #classify's first-symlink rule: find the first symlinked
|
|
127
|
+
* component, emit it, jump into the target's real namespace, and continue —
|
|
128
|
+
* so every emitted link sits at a symlink-free archive path of its own and
|
|
129
|
+
* the extractability invariant holds by construction.
|
|
130
|
+
*/
|
|
131
|
+
async #carryReferencedLinks(referencedPath) {
|
|
132
|
+
// Emissions are buffered until the whole walk succeeds. When a later hop
|
|
133
|
+
// leaves the bundle root, discovery has fallen back to bundling the content
|
|
134
|
+
// at the spelled path — real directories — and an already-emitted earlier
|
|
135
|
+
// link would then sit above those very directories, guaranteeing its own
|
|
136
|
+
// removal (and a spurious warning) at the bundler.
|
|
137
|
+
const chain = [];
|
|
138
|
+
let current = referencedPath;
|
|
139
|
+
for (;;) {
|
|
140
|
+
const symlink = await this.#firstSymlinkComponent(current);
|
|
141
|
+
if (symlink === undefined) {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
const archivePath = this.#archivePathOf(symlink);
|
|
145
|
+
if (archivePath === undefined || archivePath === '') {
|
|
146
|
+
// The "link" is the bundle root itself — the whole project is reached
|
|
147
|
+
// through a symlink, which the two-root reconciliation already absorbs.
|
|
148
|
+
// The root is not an entry; emitting one at the empty name would abort
|
|
149
|
+
// the archive.
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const target = await this.#realpath(symlink);
|
|
153
|
+
if (target === undefined) {
|
|
154
|
+
// Broken; the reference cannot resolve locally either.
|
|
155
|
+
this.#skipDanglingSymlink(symlink);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (this.#archivePathOf(target) === undefined) {
|
|
159
|
+
// The reference's content is outside the bundle root: discovery bundles
|
|
160
|
+
// it at the spelled path (or errors), so the spelled tree extracts as
|
|
161
|
+
// ordinary directories and no link entry is wanted anywhere along the
|
|
162
|
+
// spelling.
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
chain.push([symlink, target]);
|
|
166
|
+
if (current === symlink) {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
current = path.join(target, path.relative(symlink, current));
|
|
170
|
+
}
|
|
171
|
+
for (const [symlink, target] of chain) {
|
|
172
|
+
this.#emitSymlink(symlink, target);
|
|
173
|
+
// Marked separately: the same link may already be in the archive because
|
|
174
|
+
// an include pattern matched it, and being referenced is a property of
|
|
175
|
+
// the link, not of which pass got to it first.
|
|
176
|
+
this.#markLinkReferenced(symlink);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Enforces, over the finished set of entries, the two things a symlink entry
|
|
181
|
+
* must satisfy. Doing it here rather than at each emit is what makes it hold
|
|
182
|
+
* regardless of the order paths happened to be classified in.
|
|
183
|
+
*
|
|
184
|
+
* A symlink must have nothing beneath it: one path cannot be both a symlink
|
|
185
|
+
* and a directory, and tar refuses to extract an archive claiming otherwise —
|
|
186
|
+
* the whole reason this resolver exists. Where entries did land beneath a link,
|
|
187
|
+
* the link is what goes: the entries are real content and extract as ordinary
|
|
188
|
+
* files, whereas the link would take the archive down with it.
|
|
189
|
+
*
|
|
190
|
+
* A symlink must also point at something the archive contains, or it extracts
|
|
191
|
+
* into a link to nothing and the check fails at run time. Dropping one link can
|
|
192
|
+
* empty out the directory another points at, so this repeats until it settles.
|
|
193
|
+
*/
|
|
194
|
+
#pruneSymlinks() {
|
|
195
|
+
for (;;) {
|
|
196
|
+
// Every path the extracted archive will contain: each entry, and every
|
|
197
|
+
// directory tar has to create on the way to it. Symlink entries count —
|
|
198
|
+
// tar materializes their parent directories exactly as it does a file's —
|
|
199
|
+
// so a link onto a directory holding nothing but other links still
|
|
200
|
+
// resolves.
|
|
201
|
+
const occupied = new Set();
|
|
202
|
+
for (const archivePath of this.#entries.keys()) {
|
|
203
|
+
occupied.add(archivePath);
|
|
204
|
+
for (let parent = path.posix.dirname(archivePath); parent !== '.' && parent !== '/' && parent !== '' && !occupied.has(parent); parent = path.posix.dirname(parent)) {
|
|
205
|
+
occupied.add(parent);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
let pruned = false;
|
|
209
|
+
for (const [archivePath, file] of Array.from(this.#entries)) {
|
|
210
|
+
if (file.symlinkTarget === undefined) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const hasChildren = Array.from(this.#entries.keys())
|
|
214
|
+
.some(other => other.startsWith(`${archivePath}/`));
|
|
215
|
+
const target = resolveArchivePath(archivePath, file.symlinkTarget);
|
|
216
|
+
// A link onto the archive root always resolves; the root is not an
|
|
217
|
+
// entry. A link carried for a referenced path resolves too: its target
|
|
218
|
+
// content is bundled by the parser, which this resolver cannot see.
|
|
219
|
+
const resolves = target === ''
|
|
220
|
+
|| occupied.has(target)
|
|
221
|
+
|| file.referencedLink === true;
|
|
222
|
+
if (hasChildren || !resolves) {
|
|
223
|
+
debug(`Dropping symlink ${archivePath}: ${hasChildren ? 'has children' : 'target is not bundled'}`);
|
|
224
|
+
this.#entries.delete(archivePath);
|
|
225
|
+
pruned = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!pruned) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Decides how a single matched path is represented in the archive. Everything
|
|
235
|
+
* hinges on the *first* symlinked component of the path: it alone determines
|
|
236
|
+
* the mode, which is what guarantees that entries never end up beneath a
|
|
237
|
+
* symlink entry.
|
|
238
|
+
*
|
|
239
|
+
*/
|
|
240
|
+
async #classify(matchedPath) {
|
|
241
|
+
if (this.#classified.has(matchedPath)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.#classified.add(matchedPath);
|
|
245
|
+
if (isPackageManagerStateFile(matchedPath)) {
|
|
246
|
+
// Reachable when an include pattern names the file outright, since a
|
|
247
|
+
// literal dot segment matches even though wildcards do not.
|
|
248
|
+
debug(`Refusing to bundle package manager state file ${matchedPath}`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// The include glob already applied the ignore patterns to what it matched,
|
|
252
|
+
// in its own cwd namespace. Re-deciding those here would reinterpret the
|
|
253
|
+
// user's patterns in a different namespace and could drop files the glob
|
|
254
|
+
// deliberately kept. Content this resolver reached by itself, on the other
|
|
255
|
+
// hand, the glob never saw — and it is the only content that needs checking.
|
|
256
|
+
if (!this.#directPaths.has(matchedPath) && this.#isIgnored(matchedPath)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const archivePath = this.#archivePathOf(matchedPath);
|
|
260
|
+
if (archivePath === undefined) {
|
|
261
|
+
// Include patterns may be absolute, and a Playwright config may live
|
|
262
|
+
// outside the workspace root, so a matched path is not guaranteed to sit
|
|
263
|
+
// under it. Such a path has no archive path relative to the root, and
|
|
264
|
+
// therefore nothing a symlink could point at. Leave the archive path unset
|
|
265
|
+
// and let the bundler name it exactly as it did before.
|
|
266
|
+
this.#emit(matchedPath, {
|
|
267
|
+
filePath: matchedPath,
|
|
268
|
+
physical: true,
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const symlink = await this.#firstSymlinkComponent(matchedPath);
|
|
273
|
+
if (symlink === undefined) {
|
|
274
|
+
this.#emitFile(matchedPath, archivePath);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
await this.#handleSymlink(symlink, matchedPath);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Walks the path from the archive root downwards and returns the first
|
|
281
|
+
* component that is a symlink. Components above the root are never examined —
|
|
282
|
+
* a symlinked root is simply the root.
|
|
283
|
+
*/
|
|
284
|
+
async #firstSymlinkComponent(target) {
|
|
285
|
+
const root = this.#rootOf(target);
|
|
286
|
+
if (root === undefined) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const relative = path.relative(root, target);
|
|
290
|
+
let current = root;
|
|
291
|
+
for (const segment of relative.split(path.sep)) {
|
|
292
|
+
current = path.join(current, segment);
|
|
293
|
+
const stats = await this.#lstat(current);
|
|
294
|
+
if (stats?.isSymbolicLink()) {
|
|
295
|
+
return current;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
async #handleSymlink(symlink, matchedPath) {
|
|
301
|
+
const target = await this.#realpath(symlink);
|
|
302
|
+
if (target === undefined) {
|
|
303
|
+
this.#skipDanglingSymlink(symlink);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const targetArchivePath = this.#archivePathOf(target);
|
|
307
|
+
if (targetArchivePath === undefined) {
|
|
308
|
+
const symlinkArchivePath = this.#archivePathOf(symlink);
|
|
309
|
+
if (symlinkArchivePath !== undefined && this.#isIgnoredArchivePathOrContents(symlinkArchivePath)) {
|
|
310
|
+
// The escape hatch: the user excluded the link itself.
|
|
311
|
+
this.#warnOnce(symlink, `${symlink} is excluded from the bundle by the ignore patterns. Skipping the symlink.`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// For node_modules shapes the user matched outright — a package link, or
|
|
315
|
+
// a node_modules directory itself linked elsewhere (a cache volume, a
|
|
316
|
+
// relocated virtual store) — the old behaviour of silently flattening the
|
|
317
|
+
// target's contents produced bundles that only half-worked: a pnpm
|
|
318
|
+
// package's dependencies are its store siblings, which never came along.
|
|
319
|
+
// Fail loudly instead. The error is reserved for what the include
|
|
320
|
+
// patterns named directly: a link this resolver reached on its own (a
|
|
321
|
+
// store sibling pointing out of the project, say) must not turn a
|
|
322
|
+
// previously-bundling project into a hard failure.
|
|
323
|
+
const isNodeModulesShape = isInsideNodeModules(symlink) || path.basename(symlink) === NODE_MODULES;
|
|
324
|
+
if (isNodeModulesShape && this.#directPaths.has(matchedPath)) {
|
|
325
|
+
throw new Error(`${symlink} points at ${target}, which is outside the project's bundle root `
|
|
326
|
+
+ `(${this.#roots[0]}). Files outside it cannot be included in the code bundle. The `
|
|
327
|
+
+ `bundle root is your workspace root, or the nearest package.json directory when the `
|
|
328
|
+
+ `project is not part of a workspace — if the target belongs to your monorepo, make `
|
|
329
|
+
+ `sure the package containing your Checkly config is listed in the workspace `
|
|
330
|
+
+ `configuration. Otherwise, move the target inside the project, exclude the symlink `
|
|
331
|
+
+ `via ignoreDirectoriesMatch, or narrow your include patterns.`);
|
|
332
|
+
}
|
|
333
|
+
// A plain file or asset-directory link has no such failure mode: copying
|
|
334
|
+
// the bytes to the spelled path produces a complete, working bundle, as
|
|
335
|
+
// it always has. For node_modules shapes reached indirectly the copy is
|
|
336
|
+
// the best available fallback — say what it cannot deliver.
|
|
337
|
+
this.#warnOnce(symlink, isNodeModulesShape
|
|
338
|
+
? `${symlink} is linked from outside the project. Its contents will be bundled, but its `
|
|
339
|
+
+ `dependencies cannot be, so it may fail to resolve them when the check runs.`
|
|
340
|
+
: `${symlink} points outside the project. Bundling its contents instead of the symlink.`);
|
|
341
|
+
await this.#copyOutOfRootLink(matchedPath);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (this.#isIgnored(target)) {
|
|
345
|
+
// The ignore patterns exclude what this link points at, so its target will
|
|
346
|
+
// not be in the archive. Keeping the link would extract to a link pointing
|
|
347
|
+
// at nothing, which fails at run time rather than here.
|
|
348
|
+
this.#warnOnce(symlink, `${symlink} points at ${target}, which is excluded from the bundle. Skipping the symlink.`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.#emitSymlink(symlink, target);
|
|
352
|
+
const stats = await this.#statThroughLink(symlink);
|
|
353
|
+
if (stats === undefined) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (!stats.isDirectory()) {
|
|
357
|
+
// A symlink to a file cannot have children, so it is safe to keep as a
|
|
358
|
+
// link. Its target still has to be in the archive for it to resolve.
|
|
359
|
+
this.#emitFile(target, targetArchivePath);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const isPackageLink = isInsideNodeModules(symlink);
|
|
363
|
+
// A workspace member reached through a package link is handled selectively,
|
|
364
|
+
// the way the CLI treats every other workspace dependency: the link travels,
|
|
365
|
+
// the member's manifest travels, whatever the include patterns matched
|
|
366
|
+
// through the link travels — and the rest of the member's content is the
|
|
367
|
+
// import parser's business, not a wholesale directory copy.
|
|
368
|
+
//
|
|
369
|
+
// Three qualifiers, each load-bearing:
|
|
370
|
+
// - The store-shape check comes first: a pnpm store can live inside a member
|
|
371
|
+
// directory, and store packages need the expansion and sibling-closure
|
|
372
|
+
// treatment no matter where the store sits.
|
|
373
|
+
// - The target must be the member directory itself. A link into a member's
|
|
374
|
+
// subdirectory (`link:./packages/x/dist`) names content the parser will
|
|
375
|
+
// never bundle, so it keeps expansion.
|
|
376
|
+
// - The link's node_modules name must equal the member's package name. The
|
|
377
|
+
// parser resolves workspace dependencies by import specifier, so an
|
|
378
|
+
// aliased dependency (`"ui": "file:../ui"` for a package named @scope/ui)
|
|
379
|
+
// is invisible to it — selective treatment would ship an empty package.
|
|
380
|
+
// The member branch is reserved for links the include patterns matched
|
|
381
|
+
// (directly, or by matching files through them): those express user intent
|
|
382
|
+
// the parser complements. A member link this resolver reached on its own —
|
|
383
|
+
// a pnpm store package depending on a workspace member — has no parser
|
|
384
|
+
// coverage at all (the parser never reads store-internal code), so it keeps
|
|
385
|
+
// whole-target expansion below.
|
|
386
|
+
if (isPackageLink
|
|
387
|
+
&& !this.#isPnpmStoreLocation(target)
|
|
388
|
+
&& this.#memberNames.has(target)
|
|
389
|
+
&& this.#directPaths.has(matchedPath)) {
|
|
390
|
+
if (this.#linkName(symlink) === this.#memberNames.get(target)) {
|
|
391
|
+
// The emitted manifest is also what keeps the link alive: it occupies
|
|
392
|
+
// the target, so the prune pass sees the link as resolvable. If the
|
|
393
|
+
// manifest cannot be emitted, the prune drops the link rather than
|
|
394
|
+
// shipping it dangling.
|
|
395
|
+
await this.#emitMemberPackageJson(target);
|
|
396
|
+
if (matchedPath === symlink) {
|
|
397
|
+
// The include pattern named this link outright, but a workspace
|
|
398
|
+
// member travels selectively — a silent narrowing worth surfacing,
|
|
399
|
+
// since include exists for assets the import parser cannot see.
|
|
400
|
+
this.#warnOnce(`${symlink}\0member`, `${symlink} resolves to the workspace package at ${target}. Only its manifest and `
|
|
401
|
+
+ `files reached through imports or matching include patterns are bundled. To bundle `
|
|
402
|
+
+ `other files from it, add include patterns for its own path.`);
|
|
403
|
+
}
|
|
404
|
+
if (matchedPath !== symlink) {
|
|
405
|
+
await this.#classify(path.join(target, path.relative(symlink, matchedPath)));
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Expanding the target subtree is what puts the package's own files in the
|
|
411
|
+
// archive. Do it when the pattern matched the link itself, and for package
|
|
412
|
+
// links whatever the pattern's shape — `node_modules/pkg/**/*` matches only
|
|
413
|
+
// files *beneath* the link, and those files alone are not enough to run.
|
|
414
|
+
//
|
|
415
|
+
// Do NOT expand unconditionally: for a plain directory symlink that would
|
|
416
|
+
// drag in the whole target whenever a pattern merely matched something
|
|
417
|
+
// inside it, so `assets/**/*.png` with a symlinked `assets` would bundle the
|
|
418
|
+
// entire directory rather than the images.
|
|
419
|
+
//
|
|
420
|
+
// A link that points at one of its own ancestors is never expanded. pnpm
|
|
421
|
+
// creates one for a package that depends on itself (`file:.`), giving
|
|
422
|
+
// node_modules/<name> -> .., and expanding that would walk the whole project
|
|
423
|
+
// and bundle every file the include patterns deliberately left out.
|
|
424
|
+
const pointsAtAncestor = isInside(target, symlink);
|
|
425
|
+
if ((matchedPath === symlink || isPackageLink) && !pointsAtAncestor) {
|
|
426
|
+
await this.#expand(target);
|
|
427
|
+
}
|
|
428
|
+
if (isPackageLink && !pointsAtAncestor) {
|
|
429
|
+
await this.#addDependencyClosure(target);
|
|
430
|
+
}
|
|
431
|
+
if (matchedPath !== symlink) {
|
|
432
|
+
// Re-express the matched path in the real namespace and classify it there.
|
|
433
|
+
// Its remaining components may contain symlinks of their own; each ends up
|
|
434
|
+
// at its own real path, never nested under this link.
|
|
435
|
+
await this.#classify(path.join(target, path.relative(symlink, matchedPath)));
|
|
436
|
+
}
|
|
437
|
+
// Whether this link survives — whether its target contributed anything, and
|
|
438
|
+
// whether anything landed beneath the link itself — is only knowable once
|
|
439
|
+
// every path has been classified. #pruneSymlinks decides that at the end.
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* The name the link resolves as at run time — its path under the enclosing
|
|
443
|
+
* node_modules directory. When this equals the target package's declared
|
|
444
|
+
* name, the import parser can resolve the package; that equality is the
|
|
445
|
+
* member branch's precondition.
|
|
446
|
+
*/
|
|
447
|
+
#linkName(symlink) {
|
|
448
|
+
const nodeModules = enclosingNodeModules(symlink);
|
|
449
|
+
if (nodeModules === undefined) {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
return pathToPosix(path.relative(nodeModules, symlink));
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Copies an out-of-root link's content to the archive at the spelled path,
|
|
456
|
+
* where it extracts as ordinary files. Nested directory links recurse
|
|
457
|
+
* (anything they point at is out of root as well); the ancestor set cuts
|
|
458
|
+
* cycles.
|
|
459
|
+
*/
|
|
460
|
+
async #copyOutOfRootLink(matchedPath) {
|
|
461
|
+
const archivePath = this.#archivePathOf(matchedPath);
|
|
462
|
+
if (archivePath === undefined) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const stats = await this.#statThroughLink(matchedPath);
|
|
466
|
+
if (stats === undefined) {
|
|
467
|
+
// Dangling somewhere along the way; nothing to copy.
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (!stats.isDirectory()) {
|
|
471
|
+
// The bytes are readable straight through the link at the matched path,
|
|
472
|
+
// which is exactly where they belong in the archive.
|
|
473
|
+
this.#emitFile(matchedPath, archivePath);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// The matched path itself may be a nested directory link inside the
|
|
477
|
+
// out-of-root tree (glob reports such links as files); its contents belong
|
|
478
|
+
// at its archive path just like the top link's do.
|
|
479
|
+
await this.#copyTree(matchedPath, archivePath, new Set());
|
|
480
|
+
}
|
|
481
|
+
async #copyTree(directory, archiveDirectory, ancestors) {
|
|
482
|
+
const real = await this.#realpath(directory);
|
|
483
|
+
if (real === undefined || ancestors.has(real)) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
// A directory reachable by more than one route is copied once; every later
|
|
487
|
+
// route becomes a link to the first copy. Re-copying per route would take
|
|
488
|
+
// time exponential in the depth of a link fan-out. If files also arrive
|
|
489
|
+
// beneath a later route, that link gains children and the prune pass drops
|
|
490
|
+
// it in favour of them.
|
|
491
|
+
const copied = this.#copiedTrees.get(real);
|
|
492
|
+
if (copied !== undefined) {
|
|
493
|
+
if (copied !== archiveDirectory) {
|
|
494
|
+
this.#emitSymlinkEntry(directory, archiveDirectory, copied);
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
this.#copiedTrees.set(real, archiveDirectory);
|
|
499
|
+
const visited = new Set(ancestors).add(real);
|
|
500
|
+
for (const entry of await this.#enumerate(real)) {
|
|
501
|
+
const archivePath = path.posix.join(archiveDirectory, pathToPosix(path.relative(real, entry)));
|
|
502
|
+
if (this.#isIgnoredArchivePath(archivePath)) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const stats = await this.#lstat(entry);
|
|
506
|
+
if (!stats?.isSymbolicLink()) {
|
|
507
|
+
this.#emitFile(entry, archivePath);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const linkStats = await this.#statThroughLink(entry);
|
|
511
|
+
if (linkStats === undefined) {
|
|
512
|
+
// Dangling. A link to nothing is worth nothing on the runner.
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (linkStats.isDirectory()) {
|
|
516
|
+
await this.#copyTree(entry, archivePath, visited);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
this.#emitFile(entry, archivePath);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* The member's manifest carries load-bearing metadata (`type`, `exports`) and
|
|
524
|
+
* may exist in the archive only as the parser's faux placeholder; the real
|
|
525
|
+
* one wins by the registry's prefer-physical rule.
|
|
526
|
+
*/
|
|
527
|
+
async #emitMemberPackageJson(member) {
|
|
528
|
+
const packageJson = path.join(member, 'package.json');
|
|
529
|
+
// Stat through any link: a manifest that is itself a symlink still reads as
|
|
530
|
+
// a file when the archive is built.
|
|
531
|
+
const stats = await this.#statThroughLink(packageJson);
|
|
532
|
+
if (stats === undefined || !stats.isFile()) {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
const archivePath = this.#archivePathOf(packageJson);
|
|
536
|
+
if (archivePath === undefined || this.#isIgnoredArchivePath(archivePath)) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
this.#emitFile(packageJson, archivePath);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Marks a link whose target content arrives through the parser rather than
|
|
544
|
+
* through this resolver — the prune pass must not treat its target as absent,
|
|
545
|
+
* and the bundler should warn if a conflict ever forces the link out.
|
|
546
|
+
*/
|
|
547
|
+
#markLinkReferenced(symlink) {
|
|
548
|
+
const archivePath = this.#archivePathOf(symlink);
|
|
549
|
+
const existing = archivePath !== undefined ? this.#entries.get(archivePath) : undefined;
|
|
550
|
+
if (existing !== undefined && existing.symlinkTarget !== undefined) {
|
|
551
|
+
existing.referencedLink = true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** Whether a real directory sits inside a pnpm store (`.pnpm/<pkg>@<v>/node_modules/...`). */
|
|
555
|
+
#isPnpmStoreLocation(target) {
|
|
556
|
+
return pnpmStoreNodeModules(target) !== undefined;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Bundles a real directory that a symlink points at, and everything reachable
|
|
560
|
+
* from it. Deduplicated by real path, which is what makes cyclic and diamond
|
|
561
|
+
* link graphs terminate.
|
|
562
|
+
*/
|
|
563
|
+
async #expand(directory) {
|
|
564
|
+
if (this.#expanded.has(directory)) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
this.#expanded.add(directory);
|
|
568
|
+
debug(`Expanding symlink target ${directory}`);
|
|
569
|
+
for (const entry of await this.#enumerate(directory)) {
|
|
570
|
+
await this.#classify(entry);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Adds the dependency links that live alongside a package inside a pnpm store,
|
|
575
|
+
* which is the only way a bundled package can resolve what it depends on.
|
|
576
|
+
*
|
|
577
|
+
* pnpm links node_modules/debug to .pnpm/debug@4.3.4/node_modules/debug, and
|
|
578
|
+
* puts debug's own dependency ms next to that directory, at
|
|
579
|
+
* .pnpm/debug@4.3.4/node_modules/ms — a *sibling* of the link's target rather
|
|
580
|
+
* than something inside it. Expanding the target alone therefore produces a
|
|
581
|
+
* package whose dependencies are all missing.
|
|
582
|
+
*
|
|
583
|
+
* Restricted to the pnpm store on purpose. The same rule applied to a package
|
|
584
|
+
* inside an ordinary node_modules directory would enumerate that entire
|
|
585
|
+
* directory, so a single linked package could pull in everything installed.
|
|
586
|
+
*
|
|
587
|
+
* Known gap: pnpm also hoists packages into node_modules/.pnpm/node_modules,
|
|
588
|
+
* which Node reaches from inside the store. A package that requires something
|
|
589
|
+
* it does not declare resolves through there locally, and is not collected
|
|
590
|
+
* here, so it would still fail on the runner. Collecting that directory means
|
|
591
|
+
* collecting every installed package, which is far too much to pay for a case
|
|
592
|
+
* pnpm's own strictness makes rare.
|
|
593
|
+
*/
|
|
594
|
+
async #addDependencyClosure(packageDirectory) {
|
|
595
|
+
// A pnpm store package's node_modules directory looks like
|
|
596
|
+
// <...>/.pnpm/<package>@<version>/node_modules. Anything else — including a
|
|
597
|
+
// node_modules directory bundled *inside* a package — must not trigger this.
|
|
598
|
+
const nodeModules = pnpmStoreNodeModules(packageDirectory);
|
|
599
|
+
if (nodeModules === undefined) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (this.#closed.has(nodeModules)) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
this.#closed.add(nodeModules);
|
|
606
|
+
debug(`Adding dependency closure from ${nodeModules}`);
|
|
607
|
+
for (const entry of await this.#readdir(nodeModules)) {
|
|
608
|
+
if (PACKAGE_MANAGER_STATE_FILES.has(entry.name)) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const entryPath = path.join(nodeModules, entry.name);
|
|
612
|
+
if (entry.isSymbolicLink()) {
|
|
613
|
+
await this.#classify(entryPath);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (!entry.isDirectory()) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (entry.name === BIN_DIR) {
|
|
620
|
+
// Executables the package's own scripts rely on. They are ordinary
|
|
621
|
+
// files that locate themselves at run time, so copying them works.
|
|
622
|
+
for (const bin of await this.#readdir(entryPath)) {
|
|
623
|
+
await this.#classify(path.join(entryPath, bin.name));
|
|
624
|
+
}
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (entry.name.startsWith('@')) {
|
|
628
|
+
// A scoped dependency is a symlink one level inside a real scope
|
|
629
|
+
// directory, so the scope directory itself has to be opened.
|
|
630
|
+
for (const scoped of await this.#readdir(entryPath)) {
|
|
631
|
+
if (scoped.isSymbolicLink()) {
|
|
632
|
+
await this.#classify(path.join(entryPath, scoped.name));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
// A real directory here is the package itself, which #expand covers.
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* A broken symlink is not bundled. Whatever it points at does not exist here
|
|
642
|
+
* and so cannot travel with it, leaving a link to nothing on the runner. (Were
|
|
643
|
+
* it kept, #pruneSymlinks would drop it anyway, its target having no entry.)
|
|
644
|
+
*/
|
|
645
|
+
#skipDanglingSymlink(symlink) {
|
|
646
|
+
this.#warnOnce(symlink, `${symlink} is a broken symlink. Skipping it.`);
|
|
647
|
+
}
|
|
648
|
+
#emitSymlink(symlink, target) {
|
|
649
|
+
const archivePath = this.#archivePathOf(symlink);
|
|
650
|
+
const targetArchivePath = this.#archivePathOf(target);
|
|
651
|
+
if (archivePath === undefined || targetArchivePath === undefined) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
this.#emitSymlinkEntry(symlink, archivePath, targetArchivePath);
|
|
655
|
+
}
|
|
656
|
+
#emitSymlinkEntry(symlink, archivePath, targetArchivePath) {
|
|
657
|
+
// The link target is computed between archive paths, not filesystem paths,
|
|
658
|
+
// so it stays valid wherever the archive is extracted. Both are anchored to
|
|
659
|
+
// '/' first: path.posix.relative() resolves bare relative paths against the
|
|
660
|
+
// process's working directory, which has nothing to do with the archive.
|
|
661
|
+
//
|
|
662
|
+
// A link to its own parent directory relativizes to the empty string, which
|
|
663
|
+
// symlink(2) rejects, so name the directory instead.
|
|
664
|
+
const relativeTarget = path.posix.relative(path.posix.dirname(`/${archivePath}`), `/${targetArchivePath}`);
|
|
665
|
+
const symlinkTarget = relativeTarget === '' ? '.' : relativeTarget;
|
|
666
|
+
this.#emit(archivePath, {
|
|
667
|
+
filePath: symlink,
|
|
668
|
+
physical: true,
|
|
669
|
+
archivePath,
|
|
670
|
+
symlinkTarget,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
#emitFile(filePath, archivePath) {
|
|
674
|
+
this.#emit(archivePath, {
|
|
675
|
+
filePath,
|
|
676
|
+
physical: true,
|
|
677
|
+
archivePath,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
#emit(key, file) {
|
|
681
|
+
if (this.#entries.has(key)) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
this.#entries.set(key, file);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Lists the files in a real directory. Rooted at a real path on purpose: glob
|
|
688
|
+
* yields nothing at all when its cwd is a symlink.
|
|
689
|
+
*
|
|
690
|
+
* Dotfiles stay out, matching the include globs. That is not cosmetic — it is
|
|
691
|
+
* what keeps the pnpm store, its state files and stray .env files from being
|
|
692
|
+
* swept into the archive when a node_modules directory is enumerated.
|
|
693
|
+
*/
|
|
694
|
+
async #enumerate(directory) {
|
|
695
|
+
return await glob('**/*', {
|
|
696
|
+
cwd: directory,
|
|
697
|
+
nodir: true,
|
|
698
|
+
absolute: true,
|
|
699
|
+
dot: false,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Ignore patterns are matched against the path a file would occupy in the
|
|
704
|
+
* archive, not against its path relative to the include glob's cwd.
|
|
705
|
+
*
|
|
706
|
+
* The cwd is the Playwright config directory, which in a monorepo sits below
|
|
707
|
+
* the workspace root while the pnpm store sits at it — so a store path
|
|
708
|
+
* relativized against the cwd starts with `..`, and minimatch's `**` cannot
|
|
709
|
+
* swallow a `..` segment (with or without `dot`). Matching in that namespace
|
|
710
|
+
* would silently ignore nothing at all for exactly the content this resolver
|
|
711
|
+
* pulls in.
|
|
712
|
+
*
|
|
713
|
+
* The trade-off is that a pattern anchored to the cwd rather than the root —
|
|
714
|
+
* `fixtures/...` rather than a globstar-prefixed one — does not apply to
|
|
715
|
+
* expanded content. A pattern reaching outside the cwd has to be root-relative
|
|
716
|
+
* to mean anything, and the CLI's own examples are all globstar-prefixed.
|
|
717
|
+
*/
|
|
718
|
+
#isIgnored(file) {
|
|
719
|
+
const archivePath = this.#archivePathOf(file);
|
|
720
|
+
if (archivePath === undefined) {
|
|
721
|
+
// Outside the archive root, so there is no root-relative name to match.
|
|
722
|
+
// Callers that know where the file will land in the archive should use
|
|
723
|
+
// #isIgnoredArchivePath instead.
|
|
724
|
+
const relative = pathToPosix(path.relative(this.#ignoreCwd, file));
|
|
725
|
+
return this.#ignorePatterns.some(pattern => minimatch(relative, pattern, { dot: true }));
|
|
726
|
+
}
|
|
727
|
+
return this.#isIgnoredArchivePath(archivePath);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Whether the ignore patterns exclude an archive path either directly or in
|
|
731
|
+
* its entirety via a directory-shaped pattern. The distinction matters for
|
|
732
|
+
* deciding whether a *link* counts as excluded: the pattern shape the CLI's
|
|
733
|
+
* own docs teach (a globstar prefix, then the directory name, then a trailing
|
|
734
|
+
* globstar) matches everything under the directory but not the bare directory
|
|
735
|
+
* entry itself — a trailing globstar requires at least one segment. Probing
|
|
736
|
+
* with a synthetic child answers "did the user exclude this subtree" for both
|
|
737
|
+
* spellings.
|
|
738
|
+
*/
|
|
739
|
+
#isIgnoredArchivePathOrContents(archivePath) {
|
|
740
|
+
return this.#isIgnoredArchivePath(archivePath)
|
|
741
|
+
|| this.#isIgnoredArchivePath(path.posix.join(archivePath, 'x'));
|
|
742
|
+
}
|
|
743
|
+
#isIgnoredArchivePath(archivePath) {
|
|
744
|
+
return this.#ignorePatterns.some(pattern => minimatch(archivePath, pattern, { dot: true }));
|
|
745
|
+
}
|
|
746
|
+
#rootOf(target) {
|
|
747
|
+
return this.#roots.find(root => isInside(root, target));
|
|
748
|
+
}
|
|
749
|
+
#archivePathOf(target) {
|
|
750
|
+
const root = this.#rootOf(target);
|
|
751
|
+
if (root === undefined) {
|
|
752
|
+
return undefined;
|
|
753
|
+
}
|
|
754
|
+
const relative = pathToPosix(path.relative(root, target));
|
|
755
|
+
// The root itself normalizes to '.', which is not a path anything is
|
|
756
|
+
// archived at. Spell it as the empty string so it reads as "the root".
|
|
757
|
+
return relative === '.' ? '' : relative;
|
|
758
|
+
}
|
|
759
|
+
async #lstat(target) {
|
|
760
|
+
const cached = this.#lstatCache.get(target);
|
|
761
|
+
if (cached !== undefined || this.#lstatCache.has(target)) {
|
|
762
|
+
return cached;
|
|
763
|
+
}
|
|
764
|
+
let stats;
|
|
765
|
+
try {
|
|
766
|
+
stats = await fs.lstat(target);
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
stats = undefined;
|
|
770
|
+
}
|
|
771
|
+
this.#lstatCache.set(target, stats);
|
|
772
|
+
return stats;
|
|
773
|
+
}
|
|
774
|
+
/** Stats the target of a link. Undefined when the link is broken. */
|
|
775
|
+
async #statThroughLink(target) {
|
|
776
|
+
try {
|
|
777
|
+
return await fs.stat(target);
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/** Undefined when the path is a broken or cyclic symlink. */
|
|
784
|
+
async #realpath(target) {
|
|
785
|
+
try {
|
|
786
|
+
return await fs.realpath(target);
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
async #readdir(directory) {
|
|
793
|
+
try {
|
|
794
|
+
return await fs.readdir(directory, { withFileTypes: true });
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
#warnOnce(key, message) {
|
|
801
|
+
if (this.#warned.has(key)) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
this.#warned.add(key);
|
|
805
|
+
debug(message);
|
|
806
|
+
process.stderr.write(`Warning: ${message}\n`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
function isInside(root, target) {
|
|
810
|
+
return target === root || target.startsWith(root + path.sep);
|
|
811
|
+
}
|
|
812
|
+
/** Where a symlink entry's target lands, as an archive path. */
|
|
813
|
+
function resolveArchivePath(archivePath, symlinkTarget) {
|
|
814
|
+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(`/${archivePath}`), symlinkTarget));
|
|
815
|
+
// Anchored at '/' so the join cannot escape into the process's working
|
|
816
|
+
// directory; strip the anchor back off to get an archive path again.
|
|
817
|
+
return resolved.replace(/^\/+/, '');
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* pnpm's record of how a node_modules directory was built. Bundling one is worse
|
|
821
|
+
* than useless: the runner's store directory is not the one it names, and pnpm
|
|
822
|
+
* responds by purging node_modules — without asking, when CI is set.
|
|
823
|
+
*/
|
|
824
|
+
function isPackageManagerStateFile(target) {
|
|
825
|
+
return PACKAGE_MANAGER_STATE_FILES.has(path.basename(target))
|
|
826
|
+
&& path.basename(path.dirname(target)) === NODE_MODULES;
|
|
827
|
+
}
|
|
828
|
+
/** Whether a path is a package inside a node_modules directory, scope included. */
|
|
829
|
+
function isInsideNodeModules(target) {
|
|
830
|
+
return enclosingNodeModules(target) !== undefined;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* The pnpm store node_modules directory enclosing a package directory
|
|
834
|
+
* (`<...>/.pnpm/<pkg>@<v>/node_modules`), or undefined when the package does
|
|
835
|
+
* not sit in a store.
|
|
836
|
+
*/
|
|
837
|
+
function pnpmStoreNodeModules(packageDirectory) {
|
|
838
|
+
const nodeModules = enclosingNodeModules(packageDirectory);
|
|
839
|
+
if (nodeModules === undefined) {
|
|
840
|
+
return undefined;
|
|
841
|
+
}
|
|
842
|
+
return path.basename(path.dirname(path.dirname(nodeModules))) === PNPM_STORE ? nodeModules : undefined;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* The node_modules directory a package directory belongs to, looking through a
|
|
846
|
+
* scope directory when there is one: node_modules/@types/node lives two levels
|
|
847
|
+
* below its node_modules, not one.
|
|
848
|
+
*/
|
|
849
|
+
function enclosingNodeModules(packageDirectory) {
|
|
850
|
+
const parent = path.dirname(packageDirectory);
|
|
851
|
+
if (path.basename(parent) === NODE_MODULES) {
|
|
852
|
+
return parent;
|
|
853
|
+
}
|
|
854
|
+
const grandParent = path.dirname(parent);
|
|
855
|
+
if (path.basename(parent).startsWith('@') && path.basename(grandParent) === NODE_MODULES) {
|
|
856
|
+
return grandParent;
|
|
857
|
+
}
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
//# sourceMappingURL=symlink-resolver.js.map
|