@gjsify/cli 0.26.0 → 0.26.1
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/cli.gjs.mjs +47 -47
- package/lib/commands/affected.js +146 -15
- package/lib/utils/build-cache.js +7 -2
- package/lib/utils/dir-link.d.ts +58 -0
- package/lib/utils/dir-link.js +126 -0
- package/lib/utils/dlx-cache.d.ts +30 -3
- package/lib/utils/dlx-cache.js +61 -7
- package/lib/utils/install-packument-cache.d.ts +23 -0
- package/lib/utils/install-packument-cache.js +45 -10
- package/package.json +17 -17
package/lib/commands/affected.js
CHANGED
|
@@ -7,15 +7,30 @@
|
|
|
7
7
|
// up running the touched workspace + its downstream consumers instead of
|
|
8
8
|
// the whole monorepo.
|
|
9
9
|
//
|
|
10
|
+
// EVERY VERDICT THIS FILE EMITS IS SCOPED TO ONE WORKFLOW: `main.yml`. That
|
|
11
|
+
// scoping is the single most misread thing here, so it is stated before the
|
|
12
|
+
// tables rather than after. The classifier is booted only by `main.yml`'s
|
|
13
|
+
// `changes` job, and `main.yml` is the only workflow whose jobs consume its
|
|
14
|
+
// outputs. So "ignored" NEVER means "irrelevant to the repository" — it means
|
|
15
|
+
// "cannot change what `main.yml` builds or tests". Sibling workflows
|
|
16
|
+
// (`prebuilds.yml`, `audit-runtimes.yml`, `node-gi.yml`, `napi.yml`,
|
|
17
|
+
// `deploy-docs.yml`, …) gate themselves with their OWN `paths:` filters and are
|
|
18
|
+
// unaffected by anything decided here. `IGNORE` has always worked this way —
|
|
19
|
+
// `packages/node-gi/**` and `packages/napi/**` are load-bearing build inputs to
|
|
20
|
+
// their own workflows and ignored here — but the table read as a global claim,
|
|
21
|
+
// which is how a directory that is genuinely irrelevant to `main.yml` still
|
|
22
|
+
// looks like something that must not be ignored.
|
|
23
|
+
//
|
|
10
24
|
// Classifier table (first-match wins) handles the cases that aren't a
|
|
11
25
|
// straightforward "file lives in workspace X":
|
|
12
26
|
//
|
|
13
|
-
// 1. IGNORE —
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
27
|
+
// 1. IGNORE — cannot change what `main.yml` builds or tests: pure-docs /
|
|
28
|
+
// *.md / website / refs/ submodule / other workflows' files and their
|
|
29
|
+
// inputs / LICENSE / .gitignore. Discarded FIRST, before every other
|
|
30
|
+
// rule: an ignored file is never build-relevant to this workflow, not
|
|
31
|
+
// even inside a GLOBAL_TRIGGERS directory (e.g.
|
|
32
|
+
// `packages/infra/cli/README.md` must not force a full run just because
|
|
33
|
+
// `packages/infra/cli/` is a global trigger). Ignore wins over global.
|
|
19
34
|
//
|
|
20
35
|
// 2. GLOBAL_TRIGGERS — a non-ignored change touching infra the classifier
|
|
21
36
|
// itself depends on (`@gjsify/workspace`, `@gjsify/cli`, the bundler
|
|
@@ -24,12 +39,17 @@
|
|
|
24
39
|
// can't trust the closure when the algorithm that computes it just
|
|
25
40
|
// changed.
|
|
26
41
|
//
|
|
27
|
-
// 3.
|
|
42
|
+
// 3. SCRIPT_COUPLINGS — a build input that reaches its consumer through a
|
|
43
|
+
// BUILD SCRIPT instead of a dependency, so the closure walk has no edge
|
|
44
|
+
// to follow. Contributes EXTRA seeds (and may force a test tier). See
|
|
45
|
+
// the table for why this cannot be inferred and must be declared.
|
|
46
|
+
//
|
|
47
|
+
// 4. TEST_ONLY — every changed file under ONE workspace is a spec
|
|
28
48
|
// file, e2e fixture, or integration test. Seed = that workspace
|
|
29
49
|
// but SKIP the closure expansion (downstream consumers don't care
|
|
30
50
|
// about test code changes).
|
|
31
51
|
//
|
|
32
|
-
//
|
|
52
|
+
// 5. CODE (default) — `workspacesForChangedFiles` maps file → ws,
|
|
33
53
|
// then `affectedClosure` walks reverse-dep edges.
|
|
34
54
|
//
|
|
35
55
|
// Integration tests are gated separately: they run when any workspace
|
|
@@ -162,13 +182,55 @@ const GLOBAL_TRIGGERS = [
|
|
|
162
182
|
/^\.github\/workflows\/main\.yml$/,
|
|
163
183
|
/^scripts\/audit-runtimes\.mjs$/,
|
|
164
184
|
];
|
|
185
|
+
/**
|
|
186
|
+
* Inputs OWNED BY ANOTHER WORKFLOW — build-relevant, just not to `main.yml`.
|
|
187
|
+
*
|
|
188
|
+
* This is the sub-table where the workflow scoping stated at the top of the
|
|
189
|
+
* file is load-bearing, so each entry NAMES the workflow that does gate it.
|
|
190
|
+
* The test for membership is not "is this file unimportant" (none of these
|
|
191
|
+
* are) but "can `main.yml` build or test differently because of it" — and for
|
|
192
|
+
* every entry the answer is no, while the owning workflow has the path in its
|
|
193
|
+
* own `paths:` filter and would go red on a break.
|
|
194
|
+
*
|
|
195
|
+
* Adding a regex here is therefore a claim with two halves, and BOTH must
|
|
196
|
+
* hold: `main.yml` must not read the path, and some other workflow must.
|
|
197
|
+
*/
|
|
198
|
+
const OTHER_WORKFLOW_INPUTS = [
|
|
199
|
+
// Each sibling workflow's own definition. `main.yml` is deliberately
|
|
200
|
+
// absent — it is a GLOBAL_TRIGGER, since a job-shape change there is
|
|
201
|
+
// invisible until the workflow re-runs.
|
|
202
|
+
/^\.github\/workflows\/(deploy-docs|commitlint|release|release-cut|audit-runtimes|prebuilds|node-gi|napi|cli-cross-platform|build-ci-image)\.yml$/,
|
|
203
|
+
// `prebuilds.yml`'s toolchain (#838): the QEMU-emulated build script and
|
|
204
|
+
// the `changed-packages` classifier that decides which native packages a
|
|
205
|
+
// prebuild run rebuilds. `prebuilds.yml` lists `.github/prebuild-toolchain/**`
|
|
206
|
+
// in BOTH its `push` and `pull_request` `paths:` filters, so a break here
|
|
207
|
+
// reds that workflow on the same PR. `main.yml` never executes any of it —
|
|
208
|
+
// it builds no prebuild and runs no emulated leg.
|
|
209
|
+
/^\.github\/prebuild-toolchain\//,
|
|
210
|
+
// The REPO-SCOPED manifest-conformance rules (#847) — `tier`, `refs-pin`,
|
|
211
|
+
// `platforms-ci` plus the `unchecked-fields` ledger. Their gate is
|
|
212
|
+
// `audit-runtimes.yml`, which runs `node scripts/audit-runtimes.mjs
|
|
213
|
+
// --check --strict` on EVERY pull_request with no `paths:` filter at all,
|
|
214
|
+
// so this content is verified on every PR regardless of what is ignored
|
|
215
|
+
// here; `prebuilds.yml` additionally declares it a shared input (#843) and
|
|
216
|
+
// rebuilds every native package when it changes.
|
|
217
|
+
//
|
|
218
|
+
// The distinction that makes this safe is a real one, not a technicality:
|
|
219
|
+
// `main.yml` DOES run manifest-conformance code, but it reaches it through
|
|
220
|
+
// `scripts/verify-package-outputs.mjs` importing
|
|
221
|
+
// `packages/infra/manifest-conformance/lib/**` — the PACKAGE, a normal
|
|
222
|
+
// workspace under the `packages/infra/*` glob that maps and seeds like any
|
|
223
|
+
// other. Only the `scripts/`-side repo-scoped half is ignored, and that
|
|
224
|
+
// half is precisely the half `main.yml` does not load.
|
|
225
|
+
/^scripts\/manifest-conformance\//,
|
|
226
|
+
];
|
|
165
227
|
/** Patterns that contribute no seed and don't force a full run. */
|
|
166
228
|
const IGNORE = [
|
|
167
229
|
/\.md$/i,
|
|
168
230
|
/^refs\//,
|
|
169
231
|
/^website\//,
|
|
170
232
|
/^docs\//,
|
|
171
|
-
|
|
233
|
+
...OTHER_WORKFLOW_INPUTS,
|
|
172
234
|
/^\.githooks\//,
|
|
173
235
|
// @gjsify/node-gi (the Node-native GObject-Introspection engine, Axis 5) is
|
|
174
236
|
// NOT a gjsify workspace member — the GJS-first install/foreach tooling can't
|
|
@@ -200,6 +262,31 @@ const IGNORE = [
|
|
|
200
262
|
];
|
|
201
263
|
/** Patterns that suggest a test-only change. */
|
|
202
264
|
const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
|
|
265
|
+
const SCRIPT_COUPLINGS = [
|
|
266
|
+
{
|
|
267
|
+
// `templates/*` ARE workspaces (root `package.json#workspaces` carries
|
|
268
|
+
// a `templates/*` glob), so a template change already seeds its own
|
|
269
|
+
// `@gjsify/template-<name>`. That is not the consumer that matters.
|
|
270
|
+
//
|
|
271
|
+
// The consumer is `@gjsify/create-app`, and it is unreachable twice
|
|
272
|
+
// over: it lives at `packages/infra/create-gjsify/`, so no
|
|
273
|
+
// directory-name search finds it, and it takes the templates in
|
|
274
|
+
// through `node scripts/process-template.mjs` in its `build` script
|
|
275
|
+
// (which resolves each template's `workspace:^` specifiers and copies
|
|
276
|
+
// the result into `dist-templates/`), so there is no dependency edge
|
|
277
|
+
// either. `dist-templates/` is a build-output cache candidate, so once
|
|
278
|
+
// it is stale it stays stale.
|
|
279
|
+
match: /^templates\//,
|
|
280
|
+
seeds: ['@gjsify/create-app'],
|
|
281
|
+
// …and `tests/e2e/create-app` is the ONLY thing that would notice: it
|
|
282
|
+
// scaffolds a project out of `dist-templates/` and builds it. Without
|
|
283
|
+
// this a templates-only PR got neither the regenerated templates nor
|
|
284
|
+
// the suite that exercises them. PR #853 passed only by accident of
|
|
285
|
+
// also touching root `package.json` and tripping a global trigger.
|
|
286
|
+
runE2E: true,
|
|
287
|
+
why: '@gjsify/create-app build → node scripts/process-template.mjs reads templates/',
|
|
288
|
+
},
|
|
289
|
+
];
|
|
203
290
|
function classifyAndExpand(workspaces, changedFiles) {
|
|
204
291
|
const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
|
|
205
292
|
if (files.length === 0) {
|
|
@@ -250,23 +337,64 @@ function classifyAndExpand(workspaces, changedFiles) {
|
|
|
250
337
|
}
|
|
251
338
|
// Map files → workspaces. Files outside any workspace stay in `unmatched`.
|
|
252
339
|
const { matched, unmatched } = workspacesForChangedFiles(workspaces, remaining);
|
|
340
|
+
// SCRIPT_COUPLINGS: add the seeds no graph edge can reach. Applied BEFORE
|
|
341
|
+
// the `unmatched` bail-out so the table works for a coupled directory that
|
|
342
|
+
// is NOT itself a workspace — such a file would otherwise force a full run
|
|
343
|
+
// and the declared seeds would never be consulted.
|
|
344
|
+
const knownNames = new Set(workspaces.map((w) => w.name));
|
|
345
|
+
const couplingSeeds = new Set();
|
|
346
|
+
const couplingAccounted = new Set();
|
|
347
|
+
let couplingRunE2E = false;
|
|
348
|
+
for (const c of SCRIPT_COUPLINGS) {
|
|
349
|
+
const hits = remaining.filter((f) => c.match.test(f));
|
|
350
|
+
if (hits.length === 0)
|
|
351
|
+
continue;
|
|
352
|
+
for (const h of hits)
|
|
353
|
+
couplingAccounted.add(h);
|
|
354
|
+
for (const s of c.seeds) {
|
|
355
|
+
// A declared seed that is not a workspace means the table has
|
|
356
|
+
// drifted from the tree (package renamed, moved, removed). Fail
|
|
357
|
+
// towards the full run and SAY SO — the whole point of this table
|
|
358
|
+
// is that a missing rebuild here is otherwise invisible, so it must
|
|
359
|
+
// never degrade quietly back into the bug it exists to prevent.
|
|
360
|
+
if (!knownNames.has(s)) {
|
|
361
|
+
return {
|
|
362
|
+
global: true,
|
|
363
|
+
reason: `script-coupling seed ${s} is not a workspace (${c.match.source} → ${c.why}); ` +
|
|
364
|
+
`SCRIPT_COUPLINGS in commands/affected.ts is stale`,
|
|
365
|
+
workspaces: workspaces.map((w) => w.name),
|
|
366
|
+
runE2E: true,
|
|
367
|
+
runIntegration: true,
|
|
368
|
+
skipAll: false,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
couplingSeeds.add(s);
|
|
372
|
+
}
|
|
373
|
+
if (c.runE2E)
|
|
374
|
+
couplingRunE2E = true;
|
|
375
|
+
}
|
|
253
376
|
// Unmatched-but-not-ignored files are suspicious enough to fall back to
|
|
254
377
|
// the conservative "full run" path. Examples: a new top-level dotfile,
|
|
255
378
|
// a script in `scripts/` we haven't carved out, a refs/-adjacent file.
|
|
256
|
-
|
|
379
|
+
// A file a coupling claimed is accounted for and does not count here.
|
|
380
|
+
const stillUnmatched = unmatched.filter((f) => !couplingAccounted.has(f));
|
|
381
|
+
if (stillUnmatched.length > 0) {
|
|
257
382
|
return {
|
|
258
383
|
global: true,
|
|
259
|
-
reason: `unmatched files (${
|
|
384
|
+
reason: `unmatched files (${stillUnmatched.length}): ${stillUnmatched.slice(0, 3).join(', ')}${stillUnmatched.length > 3 ? '…' : ''}`,
|
|
260
385
|
workspaces: workspaces.map((w) => w.name),
|
|
261
386
|
runE2E: true,
|
|
262
387
|
runIntegration: true,
|
|
263
388
|
skipAll: false,
|
|
264
389
|
};
|
|
265
390
|
}
|
|
391
|
+
for (const s of couplingSeeds)
|
|
392
|
+
matched.add(s);
|
|
266
393
|
// TEST_ONLY shortcut: every remaining file is a spec / e2e / integration
|
|
267
394
|
// path, all under ONE workspace. Skip the closure expansion — test code
|
|
268
|
-
// has no downstream consumers.
|
|
269
|
-
|
|
395
|
+
// has no downstream consumers. A coupling deliberately disables it: the
|
|
396
|
+
// extra seeds exist precisely because a downstream consumer DOES care.
|
|
397
|
+
const testOnly = couplingSeeds.size === 0 && remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
|
|
270
398
|
if (testOnly && matched.size === 1) {
|
|
271
399
|
const only = [...matched][0];
|
|
272
400
|
// E2E or integration test-only changes still need their own job.
|
|
@@ -317,10 +445,13 @@ function classifyAndExpand(workspaces, changedFiles) {
|
|
|
317
445
|
}
|
|
318
446
|
}
|
|
319
447
|
}
|
|
320
|
-
|
|
448
|
+
// A `tests/e2e/**` touch turns the tier on, and so does any coupling that
|
|
449
|
+
// declares e2e as its only real coverage (see SCRIPT_COUPLINGS).
|
|
450
|
+
const runE2E = remaining.some((f) => f.startsWith('tests/e2e/')) || couplingRunE2E;
|
|
321
451
|
return {
|
|
322
452
|
global: false,
|
|
323
|
-
reason: `closure (${closure.size} ws from ${matched.size} seed(s))
|
|
453
|
+
reason: `closure (${closure.size} ws from ${matched.size} seed(s))` +
|
|
454
|
+
(couplingSeeds.size > 0 ? `, ${couplingSeeds.size} via script-coupling` : ''),
|
|
324
455
|
workspaces: [...closure].sort(),
|
|
325
456
|
runE2E,
|
|
326
457
|
runIntegration,
|
package/lib/utils/build-cache.js
CHANGED
|
@@ -49,10 +49,11 @@
|
|
|
49
49
|
// or a permission error degrade to "run the script uncached" — a cache
|
|
50
50
|
// hiccup must never break a build.
|
|
51
51
|
import { createHash } from 'node:crypto';
|
|
52
|
-
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync,
|
|
52
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, copyFileSync, writeFileSync, } from 'node:fs';
|
|
53
53
|
import { dirname, join } from 'node:path';
|
|
54
54
|
import { fileURLToPath } from 'node:url';
|
|
55
55
|
import { buildDependencyGraph } from '@gjsify/workspace';
|
|
56
|
+
import { replicateLinkSync } from './dir-link.js';
|
|
56
57
|
/**
|
|
57
58
|
* Bump to invalidate every existing cache entry (schema/semantic changes).
|
|
58
59
|
*
|
|
@@ -417,7 +418,11 @@ function copyDirRecursive(from, to) {
|
|
|
417
418
|
const dst = join(to, entry);
|
|
418
419
|
const st = lstatSync(src);
|
|
419
420
|
if (st.isSymbolicLink()) {
|
|
420
|
-
symlinkSync(readlinkSync(src), dst)
|
|
421
|
+
// Not `symlinkSync(readlinkSync(src), dst)`: replicating a link to a
|
|
422
|
+
// DIRECTORY verbatim needs a junction on Windows, and the target has
|
|
423
|
+
// to be resolved for it. See `dir-link.ts` — the one definition of
|
|
424
|
+
// that rule, added after the same mistake shipped in `dlx-cache.ts`.
|
|
425
|
+
replicateLinkSync(src, dst);
|
|
421
426
|
}
|
|
422
427
|
else if (st.isDirectory()) {
|
|
423
428
|
copyDirRecursive(src, dst);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link type a directory link must use on this platform.
|
|
3
|
+
*
|
|
4
|
+
* `undefined` on POSIX is deliberate: Node's default is correct there, and
|
|
5
|
+
* passing a type would be noise.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DIR_LINK_TYPE: 'junction' | undefined;
|
|
8
|
+
/** True when directory links on this platform are NTFS junctions. */
|
|
9
|
+
export declare const dirLinksAreJunctions: () => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Spell the `target` argument for a link at `linkPath` pointing at `absTarget`.
|
|
12
|
+
*
|
|
13
|
+
* Absolute on Windows because a junction demands it (gotcha 1 above); relative
|
|
14
|
+
* on POSIX so the tree stays movable (gotcha 2).
|
|
15
|
+
*
|
|
16
|
+
* `linkType` is a PARAMETER, defaulting to the host's, for the same reason
|
|
17
|
+
* `detect-native-packages.ts` takes `platform`/`arch`: the Windows branch is
|
|
18
|
+
* then unit-testable from a Linux host, which is the only place anyone runs the
|
|
19
|
+
* tests. A branch nobody can exercise is how the `'dir'` bug survived to a
|
|
20
|
+
* release.
|
|
21
|
+
*
|
|
22
|
+
* @param linkPath where the link itself will live
|
|
23
|
+
* @param absTarget the directory it should point at, as an ABSOLUTE path
|
|
24
|
+
* @param linkType override the host's link type (tests only)
|
|
25
|
+
*/
|
|
26
|
+
export declare function dirLinkTarget(linkPath: string, absTarget: string, linkType?: 'junction' | undefined): string;
|
|
27
|
+
/**
|
|
28
|
+
* Link `linkPath` → `absTarget`, choosing symlink-vs-junction and the target
|
|
29
|
+
* spelling for the host.
|
|
30
|
+
*
|
|
31
|
+
* `absTarget` must be absolute; passing a relative path is the mistake gotcha 1
|
|
32
|
+
* describes, and it would only misbehave on Windows, so it is rejected here on
|
|
33
|
+
* every platform rather than left to surface on the one host nobody tests on.
|
|
34
|
+
*/
|
|
35
|
+
export declare function linkDirSync(linkPath: string, absTarget: string): void;
|
|
36
|
+
/** Promise-returning twin of {@link linkDirSync}. */
|
|
37
|
+
export declare function linkDir(linkPath: string, absTarget: string): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Recreate an EXISTING link at a new path, preserving what it points at.
|
|
40
|
+
*
|
|
41
|
+
* A different job from {@link linkDirSync}, and it cannot reuse it: the source
|
|
42
|
+
* link's target is whatever it already is — frequently relative, deliberately so
|
|
43
|
+
* — while a junction demands an absolute one. Copying the target verbatim is
|
|
44
|
+
* therefore right on POSIX and wrong on Windows the moment the target is a
|
|
45
|
+
* directory.
|
|
46
|
+
*
|
|
47
|
+
* So the target's KIND decides: a link to a directory becomes a junction with
|
|
48
|
+
* the target resolved against the source link's own directory (the resolution
|
|
49
|
+
* Node will not do for you — see gotcha 1 above); anything else stays a plain
|
|
50
|
+
* file symlink, which Windows allows unprivileged.
|
|
51
|
+
*
|
|
52
|
+
* A dangling source link keeps the verbatim target: it points nowhere either
|
|
53
|
+
* way, and guessing a kind for it would turn a faithful copy into a fabrication.
|
|
54
|
+
*
|
|
55
|
+
* Primitive fs ops only — this runs under the GJS facade as well as Node, which
|
|
56
|
+
* is why there is no `fs.cp`/`opendir` here.
|
|
57
|
+
*/
|
|
58
|
+
export declare function replicateLinkSync(srcLink: string, dstLink: string): void;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Creating a link to a DIRECTORY, portably.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. Windows cannot create a directory *symlink* without
|
|
4
|
+
// elevation or Developer Mode — the call fails with `EPERM` — but any user can
|
|
5
|
+
// create an NTFS **junction**, which is why npm, yarn and pnpm all use junctions
|
|
6
|
+
// for directory links. `commands/install.ts` learned that and encoded it, twice,
|
|
7
|
+
// as a local constant for its workspace links. Nothing shared it, so the next
|
|
8
|
+
// site to link a directory got it wrong: `dlx-cache.ts` linked with `'dir'` and
|
|
9
|
+
// every non-elevated Windows user hit
|
|
10
|
+
//
|
|
11
|
+
// Could not resolve showcase "canvas2d-fireworks": EPERM: operation not
|
|
12
|
+
// permitted, symlink '…\<prepare-dir>' -> '…\pkg.tmp-…'
|
|
13
|
+
//
|
|
14
|
+
// on a plain `npx @gjsify/cli@latest showcase …`. The knowledge existed in the
|
|
15
|
+
// repo and the bug shipped anyway, which is the argument for a single callable
|
|
16
|
+
// definition rather than a convention.
|
|
17
|
+
//
|
|
18
|
+
// THE TWO GOTCHAS, both load-bearing:
|
|
19
|
+
//
|
|
20
|
+
// 1. **A junction target must be ABSOLUTE.** Node normalises a `'junction'`
|
|
21
|
+
// target with `path.resolve()` — against the process CWD, NOT against the
|
|
22
|
+
// link's own directory. A relative target that looks right produces a
|
|
23
|
+
// junction pointing somewhere else entirely, and only on Windows.
|
|
24
|
+
// 2. **POSIX targets should stay RELATIVE** where the caller wants a movable
|
|
25
|
+
// tree: a relative symlink survives the whole tree being moved or mounted
|
|
26
|
+
// elsewhere. So the target spelling is per-platform, not one string.
|
|
27
|
+
//
|
|
28
|
+
// Autodetection is NOT a substitute. Node picks `'file'` or `'dir'` when the
|
|
29
|
+
// type argument is omitted — never `'junction'` — so an omitted type is the
|
|
30
|
+
// failing case, not the safe default.
|
|
31
|
+
import { readlinkSync, statSync, symlinkSync } from 'node:fs';
|
|
32
|
+
import { symlink } from 'node:fs/promises';
|
|
33
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
34
|
+
/**
|
|
35
|
+
* The link type a directory link must use on this platform.
|
|
36
|
+
*
|
|
37
|
+
* `undefined` on POSIX is deliberate: Node's default is correct there, and
|
|
38
|
+
* passing a type would be noise.
|
|
39
|
+
*/
|
|
40
|
+
export const DIR_LINK_TYPE = process.platform === 'win32' ? 'junction' : undefined;
|
|
41
|
+
/** True when directory links on this platform are NTFS junctions. */
|
|
42
|
+
export const dirLinksAreJunctions = () => DIR_LINK_TYPE === 'junction';
|
|
43
|
+
/**
|
|
44
|
+
* Spell the `target` argument for a link at `linkPath` pointing at `absTarget`.
|
|
45
|
+
*
|
|
46
|
+
* Absolute on Windows because a junction demands it (gotcha 1 above); relative
|
|
47
|
+
* on POSIX so the tree stays movable (gotcha 2).
|
|
48
|
+
*
|
|
49
|
+
* `linkType` is a PARAMETER, defaulting to the host's, for the same reason
|
|
50
|
+
* `detect-native-packages.ts` takes `platform`/`arch`: the Windows branch is
|
|
51
|
+
* then unit-testable from a Linux host, which is the only place anyone runs the
|
|
52
|
+
* tests. A branch nobody can exercise is how the `'dir'` bug survived to a
|
|
53
|
+
* release.
|
|
54
|
+
*
|
|
55
|
+
* @param linkPath where the link itself will live
|
|
56
|
+
* @param absTarget the directory it should point at, as an ABSOLUTE path
|
|
57
|
+
* @param linkType override the host's link type (tests only)
|
|
58
|
+
*/
|
|
59
|
+
export function dirLinkTarget(linkPath, absTarget, linkType = DIR_LINK_TYPE) {
|
|
60
|
+
return linkType === 'junction' ? resolve(absTarget) : relative(dirname(linkPath), absTarget);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Link `linkPath` → `absTarget`, choosing symlink-vs-junction and the target
|
|
64
|
+
* spelling for the host.
|
|
65
|
+
*
|
|
66
|
+
* `absTarget` must be absolute; passing a relative path is the mistake gotcha 1
|
|
67
|
+
* describes, and it would only misbehave on Windows, so it is rejected here on
|
|
68
|
+
* every platform rather than left to surface on the one host nobody tests on.
|
|
69
|
+
*/
|
|
70
|
+
export function linkDirSync(linkPath, absTarget) {
|
|
71
|
+
assertAbsolute(absTarget);
|
|
72
|
+
symlinkSync(dirLinkTarget(linkPath, absTarget), linkPath, DIR_LINK_TYPE);
|
|
73
|
+
}
|
|
74
|
+
/** Promise-returning twin of {@link linkDirSync}. */
|
|
75
|
+
export async function linkDir(linkPath, absTarget) {
|
|
76
|
+
assertAbsolute(absTarget);
|
|
77
|
+
await symlink(dirLinkTarget(linkPath, absTarget), linkPath, DIR_LINK_TYPE);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Recreate an EXISTING link at a new path, preserving what it points at.
|
|
81
|
+
*
|
|
82
|
+
* A different job from {@link linkDirSync}, and it cannot reuse it: the source
|
|
83
|
+
* link's target is whatever it already is — frequently relative, deliberately so
|
|
84
|
+
* — while a junction demands an absolute one. Copying the target verbatim is
|
|
85
|
+
* therefore right on POSIX and wrong on Windows the moment the target is a
|
|
86
|
+
* directory.
|
|
87
|
+
*
|
|
88
|
+
* So the target's KIND decides: a link to a directory becomes a junction with
|
|
89
|
+
* the target resolved against the source link's own directory (the resolution
|
|
90
|
+
* Node will not do for you — see gotcha 1 above); anything else stays a plain
|
|
91
|
+
* file symlink, which Windows allows unprivileged.
|
|
92
|
+
*
|
|
93
|
+
* A dangling source link keeps the verbatim target: it points nowhere either
|
|
94
|
+
* way, and guessing a kind for it would turn a faithful copy into a fabrication.
|
|
95
|
+
*
|
|
96
|
+
* Primitive fs ops only — this runs under the GJS facade as well as Node, which
|
|
97
|
+
* is why there is no `fs.cp`/`opendir` here.
|
|
98
|
+
*/
|
|
99
|
+
export function replicateLinkSync(srcLink, dstLink) {
|
|
100
|
+
const rawTarget = readlinkSync(srcLink);
|
|
101
|
+
if (!dirLinksAreJunctions()) {
|
|
102
|
+
symlinkSync(rawTarget, dstLink);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const resolvedTarget = resolve(dirname(srcLink), rawTarget);
|
|
106
|
+
let targetIsDir;
|
|
107
|
+
try {
|
|
108
|
+
targetIsDir = statSync(resolvedTarget).isDirectory();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Dangling — copy it as-is rather than inventing a kind for it.
|
|
112
|
+
symlinkSync(rawTarget, dstLink);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (targetIsDir)
|
|
116
|
+
symlinkSync(resolvedTarget, dstLink, 'junction');
|
|
117
|
+
else
|
|
118
|
+
symlinkSync(rawTarget, dstLink, 'file');
|
|
119
|
+
}
|
|
120
|
+
function assertAbsolute(absTarget) {
|
|
121
|
+
if (resolve(absTarget) !== absTarget) {
|
|
122
|
+
throw new TypeError(`linkDir: target must be an absolute path, got ${JSON.stringify(absTarget)}. ` +
|
|
123
|
+
'Node resolves a Windows junction target against the process CWD rather than the ' +
|
|
124
|
+
"link's own directory, so a relative target silently points somewhere else there.");
|
|
125
|
+
}
|
|
126
|
+
}
|
package/lib/utils/dlx-cache.d.ts
CHANGED
|
@@ -20,13 +20,40 @@ export declare function getValidCachedPkg(cacheDir: string, maxAgeMinutes?: numb
|
|
|
20
20
|
* Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
|
|
21
21
|
*
|
|
22
22
|
* Strategy:
|
|
23
|
-
* 1. Create new
|
|
24
|
-
* 2. `rename(pkg
|
|
23
|
+
* 1. Create new link `<cacheDir>/pkg.tmp-<ts>-<pid>` → prepareDir.
|
|
24
|
+
* 2. `rename(pkg.tmp-…, pkg)` — POSIX guarantees rename-over-existing is atomic.
|
|
25
25
|
*
|
|
26
26
|
* Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
|
|
27
27
|
* — a parallel process won, return its realpath.
|
|
28
|
+
*
|
|
29
|
+
* WINDOWS, both halves of which shipped broken:
|
|
30
|
+
*
|
|
31
|
+
* • Step 1 used `symlinkSync(…, 'dir')`, and a directory SYMLINK needs
|
|
32
|
+
* elevation or Developer Mode there, so a plain
|
|
33
|
+
* `npx @gjsify/cli@latest showcase …` died with `EPERM: operation not
|
|
34
|
+
* permitted, symlink`. It now goes through {@link linkDirSync}, which picks
|
|
35
|
+
* an NTFS junction — the same choice npm and yarn make, and the one
|
|
36
|
+
* `commands/install.ts` already made for workspace links without sharing it.
|
|
37
|
+
* • Step 2's rename-over-existing is a POSIX guarantee, not a Windows one:
|
|
38
|
+
* `MoveFileEx` will not replace an existing DIRECTORY, and a junction is a
|
|
39
|
+
* directory reparse point. So on the second run — when `pkg` already exists
|
|
40
|
+
* — the rename failed with `EPERM`, the catch below read that as "race
|
|
41
|
+
* lost", and the function returned the OLD target. That is not a crash but
|
|
42
|
+
* it is worse than one: the cache silently never refreshed. Windows
|
|
43
|
+
* therefore unlinks first and accepts the non-atomic window, which the
|
|
44
|
+
* existing race handling already covers.
|
|
45
|
+
*
|
|
46
|
+
* @param opts.junctions force the Windows link strategy (TESTS ONLY). Defaults
|
|
47
|
+
* to the host's. It is a parameter for the same reason `dirLinkTarget` takes
|
|
48
|
+
* `linkType`: nothing in CI runs on Windows, so an injected `true` is the ONLY
|
|
49
|
+
* way the unlink-then-rename ordering above is ever EXECUTED, and an
|
|
50
|
+
* unexecutable Windows branch is exactly how the `'dir'` bug shipped. What the
|
|
51
|
+
* injection proves off-host is the ORDERING and that the second swap really
|
|
52
|
+
* refreshes; what it cannot prove is `MoveFileEx`/junction syscall behaviour.
|
|
28
53
|
*/
|
|
29
|
-
export declare function symlinkSwap(cacheDir: string, prepareDir: string
|
|
54
|
+
export declare function symlinkSwap(cacheDir: string, prepareDir: string, opts?: {
|
|
55
|
+
junctions?: boolean;
|
|
56
|
+
}): string;
|
|
30
57
|
/** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
|
|
31
58
|
export declare function cleanupStalePrepareDirs(cacheDir: string, _maxAgeMinutes?: number): void;
|
|
32
59
|
/** Resolve absolute path to the installed package's directory inside cache. */
|
package/lib/utils/dlx-cache.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// - prepare into a fresh temp dir, then atomically swap a `pkg` symlink
|
|
7
7
|
// - TTL via lstat mtime + maxAgeMinutes (default 7 days)
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
-
import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync
|
|
9
|
+
import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { join, resolve } from 'node:path';
|
|
12
|
+
import { dirLinksAreJunctions, linkDirSync } from './dir-link.js';
|
|
12
13
|
const ONE_MINUTE_MS = 60_000;
|
|
13
14
|
const DEFAULT_TTL_MIN = 60 * 24 * 7; // 7 days
|
|
14
15
|
function lexCompare(a, b) {
|
|
@@ -74,29 +75,82 @@ export function getValidCachedPkg(cacheDir, maxAgeMinutes = DEFAULT_TTL_MIN) {
|
|
|
74
75
|
* Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
|
|
75
76
|
*
|
|
76
77
|
* Strategy:
|
|
77
|
-
* 1. Create new
|
|
78
|
-
* 2. `rename(pkg
|
|
78
|
+
* 1. Create new link `<cacheDir>/pkg.tmp-<ts>-<pid>` → prepareDir.
|
|
79
|
+
* 2. `rename(pkg.tmp-…, pkg)` — POSIX guarantees rename-over-existing is atomic.
|
|
79
80
|
*
|
|
80
81
|
* Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
|
|
81
82
|
* — a parallel process won, return its realpath.
|
|
83
|
+
*
|
|
84
|
+
* WINDOWS, both halves of which shipped broken:
|
|
85
|
+
*
|
|
86
|
+
* • Step 1 used `symlinkSync(…, 'dir')`, and a directory SYMLINK needs
|
|
87
|
+
* elevation or Developer Mode there, so a plain
|
|
88
|
+
* `npx @gjsify/cli@latest showcase …` died with `EPERM: operation not
|
|
89
|
+
* permitted, symlink`. It now goes through {@link linkDirSync}, which picks
|
|
90
|
+
* an NTFS junction — the same choice npm and yarn make, and the one
|
|
91
|
+
* `commands/install.ts` already made for workspace links without sharing it.
|
|
92
|
+
* • Step 2's rename-over-existing is a POSIX guarantee, not a Windows one:
|
|
93
|
+
* `MoveFileEx` will not replace an existing DIRECTORY, and a junction is a
|
|
94
|
+
* directory reparse point. So on the second run — when `pkg` already exists
|
|
95
|
+
* — the rename failed with `EPERM`, the catch below read that as "race
|
|
96
|
+
* lost", and the function returned the OLD target. That is not a crash but
|
|
97
|
+
* it is worse than one: the cache silently never refreshed. Windows
|
|
98
|
+
* therefore unlinks first and accepts the non-atomic window, which the
|
|
99
|
+
* existing race handling already covers.
|
|
100
|
+
*
|
|
101
|
+
* @param opts.junctions force the Windows link strategy (TESTS ONLY). Defaults
|
|
102
|
+
* to the host's. It is a parameter for the same reason `dirLinkTarget` takes
|
|
103
|
+
* `linkType`: nothing in CI runs on Windows, so an injected `true` is the ONLY
|
|
104
|
+
* way the unlink-then-rename ordering above is ever EXECUTED, and an
|
|
105
|
+
* unexecutable Windows branch is exactly how the `'dir'` bug shipped. What the
|
|
106
|
+
* injection proves off-host is the ORDERING and that the second swap really
|
|
107
|
+
* refreshes; what it cannot prove is `MoveFileEx`/junction syscall behaviour.
|
|
82
108
|
*/
|
|
83
|
-
export function symlinkSwap(cacheDir, prepareDir) {
|
|
109
|
+
export function symlinkSwap(cacheDir, prepareDir, opts = {}) {
|
|
110
|
+
const junctions = opts.junctions ?? dirLinksAreJunctions();
|
|
84
111
|
const linkPath = join(cacheDir, 'pkg');
|
|
85
112
|
const tmpName = `pkg.tmp-${Date.now().toString(16)}-${process.pid.toString(16)}`;
|
|
86
113
|
const tmpLink = join(cacheDir, tmpName);
|
|
87
114
|
// If we cannot even create the tmp link, give up (the error propagates).
|
|
88
|
-
|
|
115
|
+
linkDirSync(tmpLink, prepareDir);
|
|
89
116
|
try {
|
|
117
|
+
if (junctions) {
|
|
118
|
+
// Windows: no rename-over-a-directory. Removing first opens a window
|
|
119
|
+
// in which `pkg` is absent; a concurrent reader treats that as a cache
|
|
120
|
+
// miss and prepares its own, which is correct, just wasteful. force:true
|
|
121
|
+
// keeps a first-ever swap (nothing to remove) from throwing.
|
|
122
|
+
//
|
|
123
|
+
// maxRetries/retryDelay for the same reason `install-backend-native.ts`
|
|
124
|
+
// wraps every delete in `rmWithRetry`: on Windows a delete fails with
|
|
125
|
+
// EBUSY/EPERM while ANY process holds a handle (an antivirus scan, a
|
|
126
|
+
// concurrent reader that just realpath'd this very link), and the
|
|
127
|
+
// deletion then stays PENDING — so without the retry a transient
|
|
128
|
+
// handle sends us down the "race lost" branch and the cache silently
|
|
129
|
+
// does not refresh. Free on POSIX, where the first attempt succeeds.
|
|
130
|
+
rmSync(linkPath, { force: true, recursive: false, maxRetries: 10, retryDelay: 100 });
|
|
131
|
+
}
|
|
90
132
|
renameSync(tmpLink, linkPath);
|
|
91
133
|
}
|
|
92
134
|
catch (err) {
|
|
93
135
|
const code = err.code;
|
|
94
|
-
if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST') {
|
|
136
|
+
if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST' || code === 'EACCES') {
|
|
95
137
|
// Race lost — clean up our tmp and use whoever won. force:true is
|
|
96
138
|
// the non-throwing spelling of "remove if still there"; any other
|
|
97
139
|
// failure on our own pid-unique link is real and should surface.
|
|
98
140
|
rmSync(tmpLink, { force: true });
|
|
99
|
-
|
|
141
|
+
try {
|
|
142
|
+
return realpathSync(linkPath);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Windows only, and only because the junction path unlinks before
|
|
146
|
+
// it renames: "race lost" can also mean the winner is INSIDE that
|
|
147
|
+
// non-atomic window, so `pkg` momentarily does not exist and this
|
|
148
|
+
// would throw ENOENT out of a function whose caller reports it to
|
|
149
|
+
// the user as `Could not resolve showcase "<name>"`. Our own
|
|
150
|
+
// prepareDir is a complete, correctly installed tree — a truthful
|
|
151
|
+
// answer for THIS run. The next run reads whichever link won.
|
|
152
|
+
return realpathSync(prepareDir);
|
|
153
|
+
}
|
|
100
154
|
}
|
|
101
155
|
throw err;
|
|
102
156
|
}
|
|
@@ -3,6 +3,29 @@ export interface CachedPackument {
|
|
|
3
3
|
etag: string;
|
|
4
4
|
packument: Packument;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* The cache key for a (registry, name) pair — exported so the filename rule
|
|
8
|
+
* below is a PROPERTY a test can assert, not a claim in a comment.
|
|
9
|
+
*
|
|
10
|
+
* THE SEPARATOR IS NOT COSMETIC. It used to be `|`, producing names like
|
|
11
|
+
* `corgi|https%3A%2F%2Fregistry.npmjs.org%2F|%40gjsify%2Fnode-gi.json` — and `|`
|
|
12
|
+
* is a Win32 RESERVED filename character, so every write failed with `EINVAL`.
|
|
13
|
+
* Nothing reported it: `atomicWrite` is best-effort on purpose (a read-only
|
|
14
|
+
* cache volume must not break an install) and `readCacheFile` reads an
|
|
15
|
+
* unreadable path as a MISS. So on Windows this cache was never broken loudly —
|
|
16
|
+
* it was silently DEAD, and the `If-None-Match` → `304` revalidation that is the
|
|
17
|
+
* entire reason this file exists never engaged once.
|
|
18
|
+
*
|
|
19
|
+
* `,` is legal on Win32 AND escaped by `encodeURIComponent`, which is what keeps
|
|
20
|
+
* a flat two-part name injective: an encoded part cannot contain the separator,
|
|
21
|
+
* so exactly one `(registry, name)` pair maps to any given filename.
|
|
22
|
+
*
|
|
23
|
+
* The shard key and the filename are derived from the SAME encoded parts. They
|
|
24
|
+
* used to disagree — the key hashed the raw pair while the name used the encoded
|
|
25
|
+
* one — which is how a reserved character in the name survived review of a
|
|
26
|
+
* function whose other half looked correct.
|
|
27
|
+
*/
|
|
28
|
+
export declare function packumentCacheKey(registry: string, name: string): string;
|
|
6
29
|
/**
|
|
7
30
|
* Read a cached packument + its ETag for `(registry, name)`. Returns `null` on
|
|
8
31
|
* a miss, when the cache is disabled, or when the entry is unreadable/corrupt
|