mandrel-platform 1.4.1 → 1.5.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/package.json +2 -2
- package/scripts/apply-uptime-monitors.mjs +89 -21
- package/scripts/apply-uptime-monitors.test.mjs +196 -19
- package/scripts/check-affected-mode.test.mjs +54 -1
- package/scripts/check-coverage-threshold.test.mjs +1 -1
- package/scripts/check-destructive-migration.mjs +6 -2
- package/scripts/check-osv-scan-mode.test.mjs +2 -2
- package/scripts/check-pin-drift.mjs +7 -5
- package/scripts/check-repo-settings.mjs +6 -4
- package/scripts/check-ruleset.mjs +6 -4
- package/scripts/check-runner-health.mjs +6 -4
- package/scripts/check-workflow-gh-flags.mjs +6 -2
- package/scripts/check-workflow-platform-checkout.mjs +319 -0
- package/scripts/check-workflow-platform-checkout.test.mjs +342 -0
- package/scripts/check-wrangler-baseline.mjs +126 -8
- package/scripts/check-wrangler-baseline.test.mjs +258 -1
- package/scripts/deploy-boot-smoke.mjs +1 -1
- package/scripts/deploy-worker-secrets.mjs +1 -1
- package/scripts/lib/entry-guard.mjs +111 -0
- package/scripts/lib/entry-guard.test.mjs +179 -0
- package/scripts/platform-repair.mjs +6 -4
- package/scripts/track-issue.test.mjs +280 -0
- package/scripts/update-semgrep-rules.mjs +6 -4
- package/templates/workflows/uptime-apply.yml +10 -4
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import assert from 'node:assert/strict';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
symlinkSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
15
22
|
import { tmpdir } from 'node:os';
|
|
16
23
|
import { join } from 'node:path';
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
25
|
import { afterEach, beforeEach, test } from 'node:test';
|
|
18
26
|
import {
|
|
19
27
|
parseArgv,
|
|
@@ -452,3 +460,252 @@ test('runCli --help prints usage and exits 0 without touching the filesystem', (
|
|
|
452
460
|
assert.equal(exit, 0);
|
|
453
461
|
assert.match(streams.out, /check-wrangler-baseline\.mjs/);
|
|
454
462
|
});
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// JSONC tolerance — trailing commas, and string contents left alone (#407)
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
test('parseJsonc tolerates trailing commas in objects and arrays', () => {
|
|
469
|
+
const text = `{
|
|
470
|
+
"name": "web",
|
|
471
|
+
"analytics_engine_datasets": [
|
|
472
|
+
{ "binding": "AE", "dataset": "events", },
|
|
473
|
+
],
|
|
474
|
+
}`;
|
|
475
|
+
assert.deepEqual(parseJsonc(text), {
|
|
476
|
+
name: 'web',
|
|
477
|
+
analytics_engine_datasets: [{ binding: 'AE', dataset: 'events' }],
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('parseJsonc tolerates a trailing comma separated by a comment', () => {
|
|
482
|
+
const text = '{ "a": [1, 2, /* done */ ], }';
|
|
483
|
+
assert.deepEqual(parseJsonc(text), { a: [1, 2] });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('parseJsonc leaves string contents byte-identical', () => {
|
|
487
|
+
// Each of these would be corrupted by a regex-based comment/comma stripper:
|
|
488
|
+
// `,}` and `,]` look like trailing commas, and `//` looks like a comment
|
|
489
|
+
// even when it is not preceded by a scheme colon.
|
|
490
|
+
const text = JSON.stringify({
|
|
491
|
+
braces: 'a,} b,] c',
|
|
492
|
+
slashes: 'a//b',
|
|
493
|
+
url: 'https://example.com/x',
|
|
494
|
+
block: 'a/*b*/c',
|
|
495
|
+
});
|
|
496
|
+
assert.deepEqual(parseJsonc(text), {
|
|
497
|
+
braces: 'a,} b,] c',
|
|
498
|
+
slashes: 'a//b',
|
|
499
|
+
url: 'https://example.com/x',
|
|
500
|
+
block: 'a/*b*/c',
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('parseJsonc still strips real comments outside strings', () => {
|
|
505
|
+
const text = `{
|
|
506
|
+
// line comment
|
|
507
|
+
"logpush": true, /* block */
|
|
508
|
+
"name": "x"
|
|
509
|
+
}`;
|
|
510
|
+
assert.deepEqual(parseJsonc(text), { logpush: true, name: 'x' });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('parseJsonc reports parse-error positions against the original offsets', () => {
|
|
514
|
+
// Comments and trailing commas are blanked in place, never deleted, so a
|
|
515
|
+
// position in the error message still points at the operator's file.
|
|
516
|
+
const text = '{\n // a comment\n "a": 1\n "b": 2\n}';
|
|
517
|
+
assert.throws(
|
|
518
|
+
() => parseJsonc(text),
|
|
519
|
+
(err) => /position (2[0-9]|3[0-9])/.test(err.message),
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// Advisory mode is a promise about exit codes, not about findings (#407)
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
test('runCli exits 0 on an unparseable config with --warn-only', () => {
|
|
528
|
+
writeFileSync(join(tmpDir, 'wrangler.jsonc'), '{ not valid json');
|
|
529
|
+
const streams = noopStreams();
|
|
530
|
+
const exit = runCli({
|
|
531
|
+
argv: ['--warn-only'],
|
|
532
|
+
cwd: tmpDir,
|
|
533
|
+
stdout: streams.stdout,
|
|
534
|
+
stderr: streams.stderr,
|
|
535
|
+
});
|
|
536
|
+
assert.equal(exit, 0, 'advisory mode must never hard-fail on config content');
|
|
537
|
+
assert.match(streams.err, /failed to parse/);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('runCli --json emits a parseError envelope instead of dying silently', () => {
|
|
541
|
+
writeFileSync(join(tmpDir, 'wrangler.jsonc'), '{ not valid json');
|
|
542
|
+
const streams = noopStreams();
|
|
543
|
+
const exit = runCli({
|
|
544
|
+
argv: ['--json', '--warn-only'],
|
|
545
|
+
cwd: tmpDir,
|
|
546
|
+
stdout: streams.stdout,
|
|
547
|
+
stderr: streams.stderr,
|
|
548
|
+
});
|
|
549
|
+
assert.equal(exit, 0);
|
|
550
|
+
const envelope = JSON.parse(streams.out);
|
|
551
|
+
assert.equal(envelope.kind, 'wrangler-baseline-report');
|
|
552
|
+
assert.equal(envelope.found, true);
|
|
553
|
+
assert.ok(envelope.parseError, 'the envelope names why the config was unusable');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('runCli reads a trailing-comma wrangler.jsonc end to end', () => {
|
|
557
|
+
writeFileSync(
|
|
558
|
+
join(tmpDir, 'wrangler.jsonc'),
|
|
559
|
+
`{
|
|
560
|
+
"compatibility_date": "2026-06-15",
|
|
561
|
+
"logpush": true,
|
|
562
|
+
"env": { "production": { "logpush": true, }, },
|
|
563
|
+
"analytics_engine_datasets": [{ "binding": "AE", "dataset": "events", },],
|
|
564
|
+
}`,
|
|
565
|
+
);
|
|
566
|
+
const streams = noopStreams();
|
|
567
|
+
const exit = runCli({
|
|
568
|
+
argv: [],
|
|
569
|
+
cwd: tmpDir,
|
|
570
|
+
stdout: streams.stdout,
|
|
571
|
+
stderr: streams.stderr,
|
|
572
|
+
now: new Date('2026-07-01T00:00:00Z'),
|
|
573
|
+
});
|
|
574
|
+
assert.equal(exit, 0, streams.out + streams.err);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// The consumer end state reported in #406 (Turborepo + declared exception)
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
test('runCli renders a declared exception as EXCEPTED while still reporting a real violation', () => {
|
|
582
|
+
// domio's shape: the Worker config lives outside the repo root, uses
|
|
583
|
+
// trailing commas throughout, declares an analytics-engine opt-out, and has
|
|
584
|
+
// a genuinely stale compatibility_date.
|
|
585
|
+
mkdirSync(join(tmpDir, 'apps', 'web'), { recursive: true });
|
|
586
|
+
writeFileSync(
|
|
587
|
+
join(tmpDir, 'apps', 'web', 'wrangler.jsonc'),
|
|
588
|
+
`{
|
|
589
|
+
// Worker config for the web app
|
|
590
|
+
"name": "web",
|
|
591
|
+
"compatibility_date": "2025-01-01",
|
|
592
|
+
"logpush": true,
|
|
593
|
+
"env": { "production": { "logpush": true, }, },
|
|
594
|
+
"mandrel": {
|
|
595
|
+
"wranglerBaselineExceptions": {
|
|
596
|
+
"analytics-engine": "no telemetry sink for this static-asset Worker",
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
}`,
|
|
600
|
+
);
|
|
601
|
+
const streams = noopStreams();
|
|
602
|
+
const exit = runCli({
|
|
603
|
+
argv: ['--file', 'apps/web/wrangler.jsonc', '--json'],
|
|
604
|
+
cwd: tmpDir,
|
|
605
|
+
stdout: streams.stdout,
|
|
606
|
+
stderr: streams.stderr,
|
|
607
|
+
now: new Date('2026-08-29T00:00:00Z'),
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const envelope = JSON.parse(streams.out);
|
|
611
|
+
const analytics = envelope.findings.find((f) => f.id === 'analytics-engine');
|
|
612
|
+
assert.equal(analytics.excepted, true, 'the declared opt-out is honoured');
|
|
613
|
+
assert.ok(
|
|
614
|
+
!envelope.violations.some((v) => v.id === 'analytics-engine'),
|
|
615
|
+
'an excepted rule is absent from violations',
|
|
616
|
+
);
|
|
617
|
+
assert.ok(
|
|
618
|
+
envelope.violations.some((v) => v.id === 'compat-date-stale'),
|
|
619
|
+
'a genuine staleness violation is still reported',
|
|
620
|
+
);
|
|
621
|
+
assert.equal(exit, 1, 'a real un-excepted violation still fails in blocking mode');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// Direct-invocation guard — spawned as a SUBPROCESS through a symlink (#407)
|
|
626
|
+
//
|
|
627
|
+
// Every other test here imports runCli, which is exactly why the guard bug
|
|
628
|
+
// survived: the guard line only runs when the file is the process entry point.
|
|
629
|
+
// These cases spawn it the way a pnpm consumer's CI does.
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Spawn the CLI at `scriptPath` with `cwd`, returning the child result.
|
|
636
|
+
*
|
|
637
|
+
* @param {string} scriptPath Path to invoke (possibly through a symlink).
|
|
638
|
+
* @param {string[]} args CLI args.
|
|
639
|
+
* @param {string} cwd Working directory for the child.
|
|
640
|
+
*/
|
|
641
|
+
function runScript(scriptPath, args, cwd) {
|
|
642
|
+
return spawnSync(process.execPath, [scriptPath, ...args], {
|
|
643
|
+
cwd,
|
|
644
|
+
encoding: 'utf-8',
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
test('the CLI runs when invoked through a pnpm-style symlinked node_modules', () => {
|
|
649
|
+
// node_modules/mandrel-platform -> <repo root>, the shape pnpm installs.
|
|
650
|
+
const nodeModules = join(tmpDir, 'node_modules');
|
|
651
|
+
mkdirSync(nodeModules, { recursive: true });
|
|
652
|
+
symlinkSync(REPO_ROOT, join(nodeModules, 'mandrel-platform'), 'dir');
|
|
653
|
+
writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
|
|
654
|
+
|
|
655
|
+
const linked = join(
|
|
656
|
+
nodeModules,
|
|
657
|
+
'mandrel-platform',
|
|
658
|
+
'scripts',
|
|
659
|
+
'check-wrangler-baseline.mjs',
|
|
660
|
+
);
|
|
661
|
+
const result = runScript(linked, ['--max-age-days', '90'], tmpDir);
|
|
662
|
+
|
|
663
|
+
assert.notEqual(
|
|
664
|
+
result.stdout.trim(),
|
|
665
|
+
'',
|
|
666
|
+
'a symlinked invocation must produce output, not a silent pass',
|
|
667
|
+
);
|
|
668
|
+
assert.match(result.stdout, /wrangler-baseline/);
|
|
669
|
+
assert.equal(result.status, 1, 'a violating config exits non-zero in blocking mode');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test('a symlinked invocation honours --warn-only rather than exiting silently', () => {
|
|
673
|
+
const nodeModules = join(tmpDir, 'node_modules');
|
|
674
|
+
mkdirSync(nodeModules, { recursive: true });
|
|
675
|
+
symlinkSync(REPO_ROOT, join(nodeModules, 'mandrel-platform'), 'dir');
|
|
676
|
+
writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
|
|
677
|
+
|
|
678
|
+
const linked = join(
|
|
679
|
+
nodeModules,
|
|
680
|
+
'mandrel-platform',
|
|
681
|
+
'scripts',
|
|
682
|
+
'check-wrangler-baseline.mjs',
|
|
683
|
+
);
|
|
684
|
+
const result = runScript(linked, ['--max-age-days', '90', '--warn-only'], tmpDir);
|
|
685
|
+
|
|
686
|
+
assert.equal(result.status, 0);
|
|
687
|
+
assert.match(result.stdout, /violation/i, 'advisory mode still reports the findings');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('the CLI still runs when invoked through its real path', () => {
|
|
691
|
+
writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
|
|
692
|
+
const direct = join(REPO_ROOT, 'scripts', 'check-wrangler-baseline.mjs');
|
|
693
|
+
const result = runScript(direct, ['--max-age-days', '90'], tmpDir);
|
|
694
|
+
assert.match(result.stdout, /wrangler-baseline/);
|
|
695
|
+
assert.equal(result.status, 1);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('importing the module does not execute the CLI', () => {
|
|
699
|
+
// The guard must be false when the entry point is some other script.
|
|
700
|
+
const probe = join(tmpDir, 'probe.mjs');
|
|
701
|
+
const target = join(REPO_ROOT, 'scripts', 'check-wrangler-baseline.mjs');
|
|
702
|
+
writeFileSync(
|
|
703
|
+
probe,
|
|
704
|
+
`await import(${JSON.stringify(pathToFileURL(target).href)});\nconsole.log('IMPORT-OK');\n`,
|
|
705
|
+
);
|
|
706
|
+
writeFileSync(join(tmpDir, 'wrangler.json'), '{"compatibility_date": "2020-01-01"}');
|
|
707
|
+
const result = runScript(probe, [], tmpDir);
|
|
708
|
+
assert.equal(result.status, 0, 'an import must not exit(1) via the CLI');
|
|
709
|
+
assert.match(result.stdout, /IMPORT-OK/);
|
|
710
|
+
assert.doesNotMatch(result.stdout, /wrangler-baseline/);
|
|
711
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (Story #231). Extracted from the workflow's former ~140-line inline bash so
|
|
7
7
|
* the probe is unit-testable and reviewed as code, not as a YAML diff. The
|
|
8
8
|
* workflow sparse-checks this script out of dsj1984/mandrel-platform at
|
|
9
|
-
* `
|
|
9
|
+
* `job.workflow_sha` — the exact commit the caller's
|
|
10
10
|
* `deploy-cloudflare.yml@<ref>` pin resolved to — so the script version
|
|
11
11
|
* always travels in lockstep with the workflow pin (same model as
|
|
12
12
|
* `uptime-apply.yml` → `apply-uptime-monitors.mjs`).
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* In-pipeline worker-secrets provisioning for the shared
|
|
6
6
|
* `deploy-cloudflare.yml` workflow (Story #170; extracted from inline bash in
|
|
7
7
|
* Story #231). The workflow sparse-checks this script out of
|
|
8
|
-
* dsj1984/mandrel-platform at `
|
|
8
|
+
* dsj1984/mandrel-platform at `job.workflow_sha` — the exact commit
|
|
9
9
|
* the caller's `deploy-cloudflare.yml@<ref>` pin resolved to — so the script
|
|
10
10
|
* version always travels in lockstep with the workflow pin (same model as
|
|
11
11
|
* `uptime-apply.yml` → `apply-uptime-monitors.mjs`).
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/entry-guard.mjs
|
|
3
|
+
*
|
|
4
|
+
* The single direct-invocation ("am I the process entry point?") seam shared
|
|
5
|
+
* by the `scripts/*.mjs` CLIs. Each of those scripts had grown its own guard,
|
|
6
|
+
* and they had drifted into three mutually incompatible spellings:
|
|
7
|
+
*
|
|
8
|
+
* 1. an identity comparison of the resolved `argv[1]` against the resolved
|
|
9
|
+
* pathname of `import.meta.url`;
|
|
10
|
+
* 2. a string comparison of `import.meta.url` against a `file:` URL built by
|
|
11
|
+
* interpolating `argv[1]`;
|
|
12
|
+
* 3. a suffix test — the resolved `argv[1]` ending with the script basename.
|
|
13
|
+
*
|
|
14
|
+
* Spellings 1 and 2 are both broken under pnpm, and broken in the most
|
|
15
|
+
* dangerous possible way — silently. `import.meta.url` is **realpath-resolved**
|
|
16
|
+
* by the ESM loader, while `process.argv[1]` is not: it keeps whatever path the
|
|
17
|
+
* caller typed. pnpm installs every package as a symlink
|
|
18
|
+
* (`node_modules/<pkg>` → `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>`),
|
|
19
|
+
* so a consumer invoking
|
|
20
|
+
*
|
|
21
|
+
* node node_modules/mandrel-platform/scripts/check-wrangler-baseline.mjs
|
|
22
|
+
*
|
|
23
|
+
* compares the symlinked path against the store realpath. They never match, the
|
|
24
|
+
* guard is false, the CLI never runs, and the process exits 0 having printed
|
|
25
|
+
* nothing. In a CI log that is indistinguishable from a clean pass — the gate
|
|
26
|
+
* reports success precisely because it did not run (Story #407, superseding the
|
|
27
|
+
* consumer report in #406).
|
|
28
|
+
*
|
|
29
|
+
* Spelling 2 fails a second way even without a symlink: it string-compares a
|
|
30
|
+
* URL against an unencoded path, so a relative `argv[1]` (`node scripts/x.mjs`)
|
|
31
|
+
* or any path needing percent-encoding (a space, a `#`) also misses.
|
|
32
|
+
*
|
|
33
|
+
* Spelling 3 is not broken — a path suffix survives realpath resolution — but
|
|
34
|
+
* it is loose (any file with that basename matches) and it duplicates the
|
|
35
|
+
* script's own name as a string literal that nothing keeps in sync with a
|
|
36
|
+
* rename. It is left in place where it already exists; new entry points should
|
|
37
|
+
* use this helper.
|
|
38
|
+
*
|
|
39
|
+
* `isDirectInvocation(importMetaUrl)` resolves BOTH sides through
|
|
40
|
+
* `realpathSync` so the comparison is symlink-agnostic in either direction. It
|
|
41
|
+
* is total: it never throws, and answers `false` for every shape that cannot be
|
|
42
|
+
* a direct invocation (no `argv[1]`, a deleted or unreadable entry path), so
|
|
43
|
+
* merely importing a module for its exports can never crash on the guard line
|
|
44
|
+
* and can never be mistaken for running it.
|
|
45
|
+
*
|
|
46
|
+
* This module performs no I/O beyond the two realpath probes and reads no
|
|
47
|
+
* environment, so the sibling `entry-guard.test.mjs` suite exercises it
|
|
48
|
+
* entirely offline.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { realpathSync } from 'node:fs';
|
|
52
|
+
import { resolve } from 'node:path';
|
|
53
|
+
import { fileURLToPath } from 'node:url';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a filesystem path through `realpathSync`, falling back to a plain
|
|
57
|
+
* absolute resolution when the path cannot be realpath'd.
|
|
58
|
+
*
|
|
59
|
+
* A path that does not exist has no realpath — `realpathSync` throws ENOENT.
|
|
60
|
+
* That is a normal, non-exceptional case here (`process.argv[1]` can name a
|
|
61
|
+
* file that was deleted mid-run, and is absent entirely under `node -e`), so
|
|
62
|
+
* it degrades to `resolve()` rather than propagating. Two non-existent paths
|
|
63
|
+
* then still compare equal to themselves, which keeps the guard meaningful
|
|
64
|
+
* instead of collapsing to a blanket `false`.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} candidate Path to canonicalize.
|
|
67
|
+
* @returns {string} The realpath when resolvable, else the absolute path.
|
|
68
|
+
*/
|
|
69
|
+
function canonicalize(candidate) {
|
|
70
|
+
const absolute = resolve(candidate);
|
|
71
|
+
try {
|
|
72
|
+
return realpathSync(absolute);
|
|
73
|
+
} catch {
|
|
74
|
+
return absolute;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Report whether the module identified by `importMetaUrl` is the process entry
|
|
80
|
+
* point — i.e. whether it was run as `node <this-file>` rather than imported.
|
|
81
|
+
*
|
|
82
|
+
* Symlink-safe in both directions: the entry path and the module URL are each
|
|
83
|
+
* canonicalized through `realpathSync`, so a pnpm-style symlinked
|
|
84
|
+
* `node_modules` invocation matches its own store realpath.
|
|
85
|
+
*
|
|
86
|
+
* Total by construction — never throws. A `file:` URL that cannot be converted
|
|
87
|
+
* to a path (an `http:`/`data:` specifier, a malformed URL) answers `false`,
|
|
88
|
+
* because such a module cannot be a filesystem entry point.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} importMetaUrl The calling module's `import.meta.url`.
|
|
91
|
+
* @returns {boolean} True when this module is the direct invocation target.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* if (isDirectInvocation(import.meta.url)) {
|
|
95
|
+
* process.exit(runCli());
|
|
96
|
+
* }
|
|
97
|
+
*/
|
|
98
|
+
export function isDirectInvocation(importMetaUrl) {
|
|
99
|
+
const entry = process.argv[1];
|
|
100
|
+
if (typeof entry !== 'string' || entry === '') return false;
|
|
101
|
+
if (typeof importMetaUrl !== 'string' || importMetaUrl === '') return false;
|
|
102
|
+
|
|
103
|
+
let modulePath;
|
|
104
|
+
try {
|
|
105
|
+
modulePath = fileURLToPath(importMetaUrl);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return canonicalize(entry) === canonicalize(modulePath);
|
|
111
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* entry-guard.test.mjs — node:test suite for the shared direct-invocation
|
|
4
|
+
* guard (`scripts/lib/entry-guard.mjs`, Story #407).
|
|
5
|
+
*
|
|
6
|
+
* The load-bearing case is the symlinked one: pnpm installs packages as
|
|
7
|
+
* symlinks, and the guard spelling this helper replaces compared a
|
|
8
|
+
* realpath-resolved `import.meta.url` against an unresolved `process.argv[1]`.
|
|
9
|
+
* Under pnpm those never matched, so the CLI never ran and the process exited
|
|
10
|
+
* 0 having printed nothing — a silent pass. Every assertion below that builds
|
|
11
|
+
* a symlink exists to keep that specific regression dead.
|
|
12
|
+
*
|
|
13
|
+
* Offline: the suite only creates files and symlinks under a temp dir and
|
|
14
|
+
* swaps `process.argv[1]`, which it restores after each case.
|
|
15
|
+
*
|
|
16
|
+
* Run: node --test scripts/lib/entry-guard.test.mjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import {
|
|
22
|
+
mkdtempSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
symlinkSync,
|
|
26
|
+
rmSync,
|
|
27
|
+
} from 'node:fs';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
import { isDirectInvocation } from './entry-guard.mjs';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run `fn` with `process.argv[1]` swapped to `entry`, restoring it after.
|
|
36
|
+
*
|
|
37
|
+
* @param {string | undefined} entry Value to install at `process.argv[1]`.
|
|
38
|
+
* @param {() => void} fn Body to run under the swapped argv.
|
|
39
|
+
*/
|
|
40
|
+
function withArgv(entry, fn) {
|
|
41
|
+
const original = process.argv[1];
|
|
42
|
+
if (entry === undefined) {
|
|
43
|
+
process.argv.splice(1, 1);
|
|
44
|
+
} else {
|
|
45
|
+
process.argv[1] = entry;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
fn();
|
|
49
|
+
} finally {
|
|
50
|
+
process.argv[1] = original;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a temp dir holding `store/pkg/scripts/cli.mjs` plus a `link` symlink
|
|
56
|
+
* pointing at `store/pkg` — the shape pnpm's `node_modules` produces.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{ dir: string, realScript: string, linkedScript: string }}
|
|
59
|
+
*/
|
|
60
|
+
function makeSymlinkedPackage() {
|
|
61
|
+
const dir = mkdtempSync(join(tmpdir(), 'entry-guard-test-'));
|
|
62
|
+
const pkg = join(dir, 'store', 'pkg');
|
|
63
|
+
mkdirSync(join(pkg, 'scripts'), { recursive: true });
|
|
64
|
+
const realScript = join(pkg, 'scripts', 'cli.mjs');
|
|
65
|
+
writeFileSync(realScript, '// fixture\n');
|
|
66
|
+
const link = join(dir, 'link');
|
|
67
|
+
symlinkSync(pkg, link, 'dir');
|
|
68
|
+
return { dir, realScript, linkedScript: join(link, 'scripts', 'cli.mjs') };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// The regression this helper exists for
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
test('isDirectInvocation matches when the entry path traverses a symlink', () => {
|
|
76
|
+
const { dir, realScript, linkedScript } = makeSymlinkedPackage();
|
|
77
|
+
try {
|
|
78
|
+
// The module was loaded through its realpath (what the ESM loader does),
|
|
79
|
+
// while argv[1] kept the symlinked path (what the caller typed). This is
|
|
80
|
+
// exactly the pnpm shape that used to answer false.
|
|
81
|
+
withArgv(linkedScript, () => {
|
|
82
|
+
assert.equal(
|
|
83
|
+
isDirectInvocation(pathToFileURL(realScript).href),
|
|
84
|
+
true,
|
|
85
|
+
'a symlinked invocation must still count as direct',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('isDirectInvocation matches with the symlink on the module side too', () => {
|
|
94
|
+
const { dir, realScript, linkedScript } = makeSymlinkedPackage();
|
|
95
|
+
try {
|
|
96
|
+
withArgv(realScript, () => {
|
|
97
|
+
assert.equal(isDirectInvocation(pathToFileURL(linkedScript).href), true);
|
|
98
|
+
});
|
|
99
|
+
} finally {
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Ordinary direct / imported cases
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
test('isDirectInvocation matches an unsymlinked direct invocation', () => {
|
|
109
|
+
const { dir, realScript } = makeSymlinkedPackage();
|
|
110
|
+
try {
|
|
111
|
+
withArgv(realScript, () => {
|
|
112
|
+
assert.equal(isDirectInvocation(pathToFileURL(realScript).href), true);
|
|
113
|
+
});
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('isDirectInvocation resolves a relative entry path against cwd', () => {
|
|
120
|
+
withArgv('scripts/lib/entry-guard.test.mjs', () => {
|
|
121
|
+
const absolute = join(process.cwd(), 'scripts/lib/entry-guard.test.mjs');
|
|
122
|
+
assert.equal(isDirectInvocation(pathToFileURL(absolute).href), true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('isDirectInvocation answers false when another module is the entry', () => {
|
|
127
|
+
const { dir, realScript } = makeSymlinkedPackage();
|
|
128
|
+
try {
|
|
129
|
+
const sibling = join(dir, 'store', 'pkg', 'scripts', 'other.mjs');
|
|
130
|
+
writeFileSync(sibling, '// fixture\n');
|
|
131
|
+
withArgv(sibling, () => {
|
|
132
|
+
assert.equal(isDirectInvocation(pathToFileURL(realScript).href), false);
|
|
133
|
+
});
|
|
134
|
+
} finally {
|
|
135
|
+
rmSync(dir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Totality — the guard must never throw on an import
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test('isDirectInvocation answers false when argv[1] is absent', () => {
|
|
144
|
+
withArgv(undefined, () => {
|
|
145
|
+
assert.equal(isDirectInvocation(import.meta.url), false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('isDirectInvocation answers false for an empty argv[1]', () => {
|
|
150
|
+
withArgv('', () => {
|
|
151
|
+
assert.equal(isDirectInvocation(import.meta.url), false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('isDirectInvocation tolerates an entry path that does not exist', () => {
|
|
156
|
+
const missing = join(tmpdir(), 'entry-guard-does-not-exist-407', 'cli.mjs');
|
|
157
|
+
withArgv(missing, () => {
|
|
158
|
+
assert.doesNotThrow(() => isDirectInvocation(import.meta.url));
|
|
159
|
+
assert.equal(isDirectInvocation(import.meta.url), false);
|
|
160
|
+
// A non-existent path still compares equal to itself rather than
|
|
161
|
+
// collapsing to a blanket false.
|
|
162
|
+
assert.equal(isDirectInvocation(pathToFileURL(missing).href), true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('isDirectInvocation answers false for a non-file module URL', () => {
|
|
167
|
+
withArgv('/tmp/whatever.mjs', () => {
|
|
168
|
+
assert.equal(isDirectInvocation('https://example.com/cli.mjs'), false);
|
|
169
|
+
assert.equal(isDirectInvocation('data:text/javascript,0'), false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('isDirectInvocation answers false for a missing or malformed URL', () => {
|
|
174
|
+
withArgv('/tmp/whatever.mjs', () => {
|
|
175
|
+
assert.equal(isDirectInvocation(''), false);
|
|
176
|
+
assert.equal(isDirectInvocation(/** @type {any} */ (undefined)), false);
|
|
177
|
+
assert.equal(isDirectInvocation('not a url'), false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -77,6 +77,8 @@ import {
|
|
|
77
77
|
import { defaultGhRunner } from "./lib/gh-json.mjs";
|
|
78
78
|
import { parseSemver } from "./lib/semver-duration.mjs";
|
|
79
79
|
|
|
80
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
81
|
+
|
|
80
82
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
81
83
|
|
|
82
84
|
// The stable head branch every repair PR is opened from. Keying idempotency off
|
|
@@ -782,9 +784,9 @@ export function runCli({
|
|
|
782
784
|
return 0;
|
|
783
785
|
}
|
|
784
786
|
|
|
785
|
-
// Direct-invocation guard
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
if (
|
|
787
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
788
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
789
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
790
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
789
791
|
process.exit(runCli());
|
|
790
792
|
}
|