pmsec 2026.10.1 → 2026.10.4

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/README.md CHANGED
@@ -25,7 +25,7 @@ uvx --index https://pypi.org/simple --exclude-newer-package pmsec=2099-01-01 pms
25
25
  | `pmsec --doctor` | Prints read-only path, ownership, and writability diagnostics. |
26
26
  | `pmsec --version` | Prints the version. |
27
27
 
28
- Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv,bundler,aube`, `--days N`,
28
+ Options: `--tool npm,pnpm,yarn,bun,cargo,mise,uv,bundler,aube,composer`, `--days N`,
29
29
  `--force`, `--json`.
30
30
 
31
31
  Write failures include the target path and ownership hint. pmsec never escalates
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "pmsec",
3
- "version": "2026.10.1",
3
+ "version": "2026.10.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Zero-config install-time supply-chain hardening for npm, pnpm, yarn, bun, cargo, mise, uv, and bundler.",
7
+ "description": "Zero-config install-time supply-chain hardening for npm, pnpm, yarn, bun, cargo, mise, uv, bundler, aube, and composer.",
8
8
  "homepage": "https://github.com/HikaruEgashira/pmsec#readme",
9
9
  "repository": {
10
10
  "type": "git",
@@ -43,6 +43,8 @@
43
43
  "cargo",
44
44
  "mise",
45
45
  "uv",
46
+ "bundler",
47
+ "composer",
46
48
  "pmsec"
47
49
  ]
48
50
  }
package/src/cli.mjs CHANGED
@@ -11,8 +11,9 @@ import * as mise from "./tools/mise.mjs";
11
11
  import * as uv from "./tools/uv.mjs";
12
12
  import * as bundler from "./tools/bundler.mjs";
13
13
  import * as aube from "./tools/aube.mjs";
14
+ import * as composer from "./tools/composer.mjs";
14
15
 
15
- const TOOLS = [npm, pnpm, yarn, bun, cargo, mise, uv, bundler, aube];
16
+ const TOOLS = [npm, pnpm, yarn, bun, cargo, mise, uv, bundler, aube, composer];
16
17
  // Default cooldown for the hardening bundle. Override per-invocation with
17
18
  // `--days N`; the default tracks the safest value we'd recommend.
18
19
  const BUNDLE_DAYS = 1;
@@ -23,7 +24,7 @@ export const VERSION = JSON.parse(
23
24
  const USAGE = `pmsec [options]
24
25
 
25
26
  Zero-config install-time supply-chain hardening across npm, pnpm, yarn,
26
- bun, cargo, mise, uv, bundler, aube. Default action enables every safe-by-default
27
+ bun, cargo, mise, uv, bundler, aube, composer. Default action enables every safe-by-default
27
28
  key each tool exposes (cooldown, audit-level, trust-policy, hardened mode,
28
29
  attestation re-verification, ...). No knobs.
29
30
 
@@ -31,7 +32,7 @@ Options:
31
32
  --check Verify the bundle is in place (exit 1 if anything missing)
32
33
  --disable Remove the hardening bundle from selected tools
33
34
  --doctor Diagnose effective paths/owner/uid (read-only; for unattended-deployment debugging)
34
- --tool TOOL[,TOOL] Restrict to specific tools (npm,pnpm,yarn,bun,cargo,mise,uv,bundler,aube)
35
+ --tool TOOL[,TOOL] Restrict to specific tools (npm,pnpm,yarn,bun,cargo,mise,uv,bundler,aube,composer)
35
36
  --days N Override cooldown days (default 1)
36
37
  --force Overwrite stricter existing cooldowns (otherwise enable is monotonic)
37
38
  --json Emit JSON output
@@ -99,7 +100,7 @@ async function gatherStatus(targets, ctx) {
99
100
  return Promise.all(targets.map(async t => {
100
101
  const r = await t.read(ctx);
101
102
  return {
102
- tool: t.name, key: t.key, path: r.path,
103
+ tool: t.name, key: r.key ?? t.key, path: r.path,
103
104
  configured: r.configured, days: r.days,
104
105
  extras: r.extras ?? [],
105
106
  warn: preflightWarn(t, ctx)
@@ -1,55 +1,49 @@
1
1
  import { cargoConfigPath } from "../util/paths.mjs";
2
2
  import { readSafe, writeAtomic } from "../util/io.mjs";
3
3
  import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
+ import { detectVersion, gte } from "../util/version.mjs";
4
5
 
5
6
  export const name = "cargo";
6
- export const key = "minimum-release-age";
7
- export const section = "install";
8
- export const docs = "https://rust-lang.github.io/rfcs/3801-package-cooldown.html";
7
+ export const key = "global-min-publish-age";
8
+ export const section = "registry";
9
+ export const docs = "https://doc.rust-lang.org/nightly/cargo/reference/config.html#registryglobal-min-publish-age";
10
+ export const minBin = [1, 100, 0];
9
11
  export const extras = [];
10
12
 
11
- // Stabilized resolver cooldown (cargo >= 1.100). It tracks `--days` like the
12
- // primary key, so it is handled here rather than as a fixed-value extra.
13
- const publishAge = { key: "global-min-publish-age", section: "registry" };
13
+ // pmsec <= 2026.10.1 wrote this key, but cargo never supported it. Remove it
14
+ // when we touch the file so users do not mistake it for an enforced setting.
15
+ const legacy = { key: "minimum-release-age", section: "install" };
14
16
 
15
17
  export function path(ctx) { return cargoConfigPath(ctx.env, ctx.home); }
16
18
 
17
19
  function parseDays(value) {
18
20
  if (value === null) return null;
19
- const m = value.match(/^"?\s*(\d+)\s*(d|days?|w|weeks?)\s*"?$/i);
21
+ const m = value.match(/^"?\s*(\d+)\s*(d|days?|w|weeks?|months?)\s*"?$/i);
20
22
  if (!m) return null;
21
23
  const n = Number(m[1]);
22
- return /^(w|weeks?)$/i.test(m[2]) ? n * 7 : n;
24
+ if (/^(w|weeks?)$/i.test(m[2])) return n * 7;
25
+ if (/^(months?)$/i.test(m[2])) return n * 30;
26
+ return n;
23
27
  }
24
28
 
25
- function parsePublishAgeDays(value) {
26
- if (value === null) return null;
27
- const m = value.match(/^"\s*(\d+)\s*(days?|weeks?|months?)\s*"$/i);
28
- if (!m) return null;
29
- const n = Number(m[1]);
30
- if (/^weeks?$/i.test(m[2])) return n * 7;
31
- if (/^months?$/i.test(m[2])) return n * 30;
32
- return n;
29
+ export function preflight(ctx) {
30
+ const v = detectVersion("cargo", ["--version"], { env: ctx.env, overrideKey: "PMSEC_CARGO_VERSION" });
31
+ if (v === null) return { ok: true, message: null };
32
+ if (gte(v, minBin)) return { ok: true, version: v.raw, message: null };
33
+ return { ok: true, warn: true, version: v.raw, message: `cargo ${v.raw} < ${minBin.join(".")}: global-min-publish-age is silently ignored. Upgrade cargo to enforce the cooldown.` };
33
34
  }
34
35
 
35
36
  export async function read(ctx) {
36
37
  const p = path(ctx);
37
38
  const raw = await readSafe(p);
38
39
  const value = readKey(raw, key, { section });
39
- const days = parseDays(value);
40
- const cur = readKey(raw, publishAge.key, { section: publishAge.section });
41
- const want = days ?? 1;
42
- const curDays = parsePublishAgeDays(cur);
43
- return {
44
- path: p, configured: value, days,
45
- extras: [{ key: publishAge.key, configured: cur, expected: `"${want} days"`, ok: curDays !== null && curDays >= want }],
46
- };
40
+ return { path: p, configured: value, days: parseDays(value), extras: [] };
47
41
  }
48
42
 
49
43
  export async function write(days, ctx) {
50
44
  const p = path(ctx);
51
- let text = setKey(await readSafe(p), key, `${key} = "${days}d"`, { section });
52
- text = setKey(text, publishAge.key, `${publishAge.key} = "${days} days"`, { section: publishAge.section });
45
+ let text = setKey(await readSafe(p), key, `${key} = "${days} days"`, { section });
46
+ text = removeKey(text, legacy.key, { section: legacy.section }).text;
53
47
  await writeAtomic(p, text);
54
48
  return { path: p };
55
49
  }
@@ -58,8 +52,8 @@ export async function unset(ctx) {
58
52
  const p = path(ctx);
59
53
  const before = await readSafe(p);
60
54
  const cooldown = removeKey(before, key, { section });
61
- const age = removeKey(cooldown.text, publishAge.key, { section: publishAge.section });
62
- const removed = cooldown.removed || age.removed;
63
- if (removed) await writeAtomic(p, age.text);
55
+ const old = removeKey(cooldown.text, legacy.key, { section: legacy.section });
56
+ const removed = cooldown.removed || old.removed;
57
+ if (removed) await writeAtomic(p, old.text);
64
58
  return { path: p, removed };
65
59
  }
@@ -0,0 +1,82 @@
1
+ import { composerConfigPath } from "../util/paths.mjs";
2
+ import { readSafe, writeAtomic } from "../util/io.mjs";
3
+ import { readJsonKey, setJsonKey, removeJsonKey } from "../util/jsonconf.mjs";
4
+
5
+ export const name = "composer";
6
+ export const key = "minimum-release-age";
7
+ export const section = "config";
8
+ export const docs = "https://getcomposer.org/doc/06-config.md#minimum-release-age";
9
+ export const extras = [
10
+ { key: "allow-plugins", expected: "false", line: "false", path: ["config", "allow-plugins"] },
11
+ { key: "secure-http", expected: "true", line: "true", path: ["config", "secure-http"] },
12
+ { key: "disable-tls", expected: "false", line: "false", path: ["config", "disable-tls"] },
13
+ { key: "abandoned", expected: '"fail"', line: '"fail"', path: ["config", "audit", "abandoned"] },
14
+ ];
15
+
16
+ // Composer is newer than most tools in the bundle; accept the shorter npm
17
+ // spelling too so a config written by hand is still recognized.
18
+ const PRIMARY_ALIASES = [key, "min-release-age", "minimum_release_age", "minimumReleaseAge"];
19
+
20
+ export function path(ctx) { return composerConfigPath(ctx.env, ctx.home, ctx.platform); }
21
+
22
+ function parseDays(value) {
23
+ if (value === null) return null;
24
+ const v = String(value).trim().replace(/^"|"$/g, "").trim();
25
+ const dur = v.match(/^(\d+)\s*(d|days?|w|weeks?)$/i);
26
+ if (dur) {
27
+ const n = Number(dur[1]);
28
+ return /^(w|weeks?)$/i.test(dur[2]) ? n * 7 : n;
29
+ }
30
+ if (!/^\d+$/.test(v)) return null;
31
+ const n = Number(v);
32
+ // Large values are seconds or minutes (the units Composer uses for its
33
+ // other time settings); small values are days.
34
+ if (n >= 86400) return Math.floor(n / 86400);
35
+ if (n >= 1440 && n % 1440 === 0) return n / 1440;
36
+ return n;
37
+ }
38
+
39
+ export async function read(ctx) {
40
+ const p = path(ctx);
41
+ const raw = await readSafe(p);
42
+ let configured = null;
43
+ for (const candidate of PRIMARY_ALIASES) {
44
+ const v = readJsonKey(raw, ["config", candidate]);
45
+ if (v !== null) { configured = v; break; }
46
+ }
47
+ return {
48
+ path: p, configured, days: parseDays(configured),
49
+ extras: extras.map(e => {
50
+ const cur = readJsonKey(raw, e.path);
51
+ const safeValues = e.safeValues ?? [e.expected];
52
+ return { key: e.key, configured: cur, expected: e.expected, ok: cur !== null && safeValues.includes(cur) };
53
+ }),
54
+ };
55
+ }
56
+
57
+ export async function write(days, ctx) {
58
+ const p = path(ctx);
59
+ let text = await readSafe(p);
60
+ text = setJsonKey(text, ["config", key], `${days}`);
61
+ for (const e of extras) text = setJsonKey(text, e.path, e.line);
62
+ await writeAtomic(p, text);
63
+ return { path: p };
64
+ }
65
+
66
+ export async function unset(ctx) {
67
+ const p = path(ctx);
68
+ let text = await readSafe(p);
69
+ let removed = false;
70
+ for (const candidate of PRIMARY_ALIASES) {
71
+ const r = removeJsonKey(text, ["config", candidate]);
72
+ text = r.text;
73
+ if (r.removed) removed = true;
74
+ }
75
+ for (const e of extras) {
76
+ const r = removeJsonKey(text, e.path);
77
+ text = r.text;
78
+ if (r.removed) removed = true;
79
+ }
80
+ if (removed) await writeAtomic(p, text);
81
+ return { path: p, removed };
82
+ }
@@ -1,4 +1,4 @@
1
- import { pnpmRcPath } from "../util/paths.mjs";
1
+ import { pnpmRcPath, pnpmConfigPath } from "../util/paths.mjs";
2
2
  import { readSafe, writeAtomic } from "../util/io.mjs";
3
3
  import { readKey, setKey, removeKey } from "../util/lines.mjs";
4
4
  import { readExtras, applyExtras, removeExtras } from "../util/extras.mjs";
@@ -8,21 +8,38 @@ export const name = "pnpm";
8
8
  export const key = "minimum-release-age";
9
9
  export const docs = "https://pnpm.io/settings#minimumreleaseage";
10
10
  export const minBin = [10, 6, 0];
11
+ // pnpm >= 11 only reads pnpm-specific settings from the camelCase YAML config.
12
+ // We keep writing the kebab-case rc file for pnpm 10 and mirror every setting
13
+ // into config.yaml for pnpm 11+. `read()` switches files based on the detected
14
+ // major version so `--check` validates the config the running pnpm actually
15
+ // reads.
16
+ const yamlKey = "minimumReleaseAge";
11
17
  // `defaultSinceMajor`: pnpm major version where the value became the default.
12
- // Once detected, an absent line in the rc file is still effectively in force,
13
- // so `read()` reports it as ok with `defaultEnforced: true`.
18
+ // Once detected, an absent line in the active config is still effectively in
19
+ // force, so `read()` reports it as ok with `defaultEnforced: true`.
14
20
  export const extras = [
15
- { key: "trust-policy", expected: "no-downgrade", line: "trust-policy=no-downgrade" },
16
- { key: "block-exotic-subdeps", expected: "true", line: "block-exotic-subdeps=true", defaultSinceMajor: 11 },
17
- { key: "strict-dep-builds", expected: "true", line: "strict-dep-builds=true" },
18
- { key: "verify-deps-before-run", expected: "error", line: "verify-deps-before-run=error" },
19
- { key: "minimum-release-age-strict", expected: "true", line: "minimum-release-age-strict=true" },
20
- { key: "dangerously-allow-all-builds", expected: "false", line: "dangerously-allow-all-builds=false" },
21
- { key: "trust-lockfile", expected: "false", line: "trust-lockfile=false" },
22
- { key: "minimum-release-age-ignore-missing-time", expected: "false", line: "minimum-release-age-ignore-missing-time=false" },
21
+ { key: "trust-policy", yamlKey: "trustPolicy", expected: "no-downgrade", line: "trust-policy=no-downgrade", yamlLine: "trustPolicy: no-downgrade" },
22
+ { key: "block-exotic-subdeps", yamlKey: "blockExoticSubdeps", expected: "true", line: "block-exotic-subdeps=true", yamlLine: "blockExoticSubdeps: true", defaultSinceMajor: 11 },
23
+ { key: "strict-dep-builds", yamlKey: "strictDepBuilds", expected: "true", line: "strict-dep-builds=true", yamlLine: "strictDepBuilds: true" },
24
+ { key: "verify-deps-before-run", yamlKey: "verifyDepsBeforeRun", expected: "error", line: "verify-deps-before-run=error", yamlLine: "verifyDepsBeforeRun: error" },
25
+ { key: "minimum-release-age-strict", yamlKey: "minimumReleaseAgeStrict", expected: "true", line: "minimum-release-age-strict=true", yamlLine: "minimumReleaseAgeStrict: true" },
26
+ { key: "dangerously-allow-all-builds", yamlKey: "dangerouslyAllowAllBuilds", expected: "false", line: "dangerously-allow-all-builds=false", yamlLine: "dangerouslyAllowAllBuilds: false" },
27
+ { key: "trust-lockfile", yamlKey: "trustLockfile", expected: "false", line: "trust-lockfile=false", yamlLine: "trustLockfile: false" },
28
+ { key: "minimum-release-age-ignore-missing-time", yamlKey: "minimumReleaseAgeIgnoreMissingTime", expected: "false", line: "minimum-release-age-ignore-missing-time=false", yamlLine: "minimumReleaseAgeIgnoreMissingTime: false" },
23
29
  ];
24
30
 
31
+ function yamlExtras() {
32
+ return extras.map(e => ({
33
+ key: e.yamlKey,
34
+ expected: e.expected,
35
+ line: e.yamlLine,
36
+ sep: ":",
37
+ ...(e.defaultSinceMajor ? { defaultSinceMajor: e.defaultSinceMajor } : {}),
38
+ }));
39
+ }
40
+
25
41
  export function path(ctx) { return pnpmRcPath(ctx.env, ctx.home, ctx.platform); }
42
+ export function configPath(ctx) { return pnpmConfigPath(ctx.env, ctx.home, ctx.platform); }
26
43
 
27
44
  // Cache `pnpm --version` for the lifetime of the process. preflight() and
28
45
  // read() both want it; without memoization a single `pmsec check` spawns
@@ -37,6 +54,14 @@ function pnpmVersion(ctx) {
37
54
  return v;
38
55
  }
39
56
 
57
+ function activeConfig(ctx) {
58
+ const v = pnpmVersion(ctx);
59
+ if (v && v.major >= 11) {
60
+ return { path: configPath(ctx), key: yamlKey, sep: ":", extras: yamlExtras() };
61
+ }
62
+ return { path: path(ctx), key, sep: "=", extras };
63
+ }
64
+
40
65
  export function preflight(ctx) {
41
66
  const v = pnpmVersion(ctx);
42
67
  if (v === null) return { ok: true, message: null };
@@ -45,13 +70,13 @@ export function preflight(ctx) {
45
70
  }
46
71
 
47
72
  export async function read(ctx) {
48
- const p = path(ctx);
49
- const raw = await readSafe(p);
50
- const value = readKey(raw, key);
73
+ const active = activeConfig(ctx);
74
+ const raw = await readSafe(active.path);
75
+ const value = readKey(raw, active.key, { sep: active.sep });
51
76
  const minutes = value === null ? null : Number(value);
52
77
  const v = pnpmVersion(ctx);
53
- const defaults = new Map(extras.filter(x => x.defaultSinceMajor).map(x => [x.key, x.defaultSinceMajor]));
54
- const evaluated = readExtras(raw, extras).map(e => {
78
+ const defaults = new Map(active.extras.filter(x => x.defaultSinceMajor).map(x => [x.key, x.defaultSinceMajor]));
79
+ const evaluated = readExtras(raw, active.extras).map(e => {
55
80
  const def = defaults.get(e.key);
56
81
  if (e.configured === null && def && v && v.major >= def) {
57
82
  return { ...e, ok: true, defaultEnforced: true };
@@ -59,7 +84,7 @@ export async function read(ctx) {
59
84
  return e;
60
85
  });
61
86
  return {
62
- path: p, configured: value,
87
+ path: active.path, key: active.key, configured: value,
63
88
  days: minutes === null || Number.isNaN(minutes) ? null : Math.floor(minutes / (60 * 24)),
64
89
  extras: evaluated
65
90
  };
@@ -70,6 +95,11 @@ export async function write(days, ctx) {
70
95
  let text = setKey(await readSafe(p), key, `${key}=${days * 24 * 60}`);
71
96
  text = applyExtras(text, extras);
72
97
  await writeAtomic(p, text);
98
+
99
+ const cp = configPath(ctx);
100
+ let yaml = setKey(await readSafe(cp), yamlKey, `${yamlKey}: ${days * 24 * 60}`, { sep: ":" });
101
+ yaml = applyExtras(yaml, yamlExtras());
102
+ await writeAtomic(cp, yaml);
73
103
  return { path: p };
74
104
  }
75
105
 
@@ -78,7 +108,16 @@ export async function unset(ctx) {
78
108
  const before = await readSafe(p);
79
109
  const cooldown = removeKey(before, key);
80
110
  const ex = removeExtras(cooldown.text, extras);
81
- const removed = cooldown.removed || ex.removed;
111
+ let removed = cooldown.removed || ex.removed;
82
112
  if (removed) await writeAtomic(p, ex.text);
113
+
114
+ const cp = configPath(ctx);
115
+ const yamlBefore = await readSafe(cp);
116
+ const yamlCooldown = removeKey(yamlBefore, yamlKey, { sep: ":" });
117
+ const yamlEx = removeExtras(yamlCooldown.text, yamlExtras());
118
+ if (yamlCooldown.removed || yamlEx.removed) {
119
+ removed = true;
120
+ await writeAtomic(cp, yamlEx.text);
121
+ }
83
122
  return { path: p, removed };
84
123
  }
@@ -0,0 +1,226 @@
1
+ // Minimal line-oriented JSON object editor.
2
+ //
3
+ // Composer stores its user config as JSON. Pulling in a full JSON round-trip
4
+ // would reorder/reformat unrelated keys (and the bash port has no JSON parser),
5
+ // so pmsec edits the pretty-printed object in place instead. Only the shapes
6
+ // pmsec needs are supported: objects of scalar members, one member per line.
7
+
8
+ function normalizeLines(lines) {
9
+ const out = [];
10
+ for (const line of lines) {
11
+ let current = "", inString = false, escaped = false;
12
+ for (let i = 0; i < line.length; i++) {
13
+ const ch = line[i];
14
+ if (inString) {
15
+ current += ch;
16
+ if (escaped) escaped = false;
17
+ else if (ch === "\\") escaped = true;
18
+ else if (ch === '"') inString = false;
19
+ continue;
20
+ }
21
+ if (ch === '"') { inString = true; current += ch; continue; }
22
+ if (ch === "{" || ch === ",") {
23
+ current += ch;
24
+ if (line.slice(i + 1).trim() !== "") { out.push(current); current = ""; }
25
+ } else if (ch === "}") {
26
+ if (current.trim() !== "") { out.push(current); current = ""; }
27
+ current += ch;
28
+ } else {
29
+ current += ch;
30
+ }
31
+ }
32
+ if (current.trim() !== "" || out.length === 0) out.push(current);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function scanDepth(line, depth) {
38
+ let inString = false, escaped = false;
39
+ for (const ch of line) {
40
+ if (inString) {
41
+ if (escaped) escaped = false;
42
+ else if (ch === "\\") escaped = true;
43
+ else if (ch === '"') inString = false;
44
+ } else if (ch === '"') inString = true;
45
+ else if (ch === "{") depth++;
46
+ else if (ch === "}") depth--;
47
+ }
48
+ return depth;
49
+ }
50
+
51
+ function depthMaps(lines) {
52
+ const start = [], end = [];
53
+ let depth = 0;
54
+ for (const line of lines) {
55
+ start.push(depth);
56
+ depth = scanDepth(line, depth);
57
+ end.push(depth);
58
+ }
59
+ return { start, end };
60
+ }
61
+
62
+ function rootRange(lines, dm) {
63
+ let open = -1;
64
+ for (let i = 0; i < lines.length; i++) {
65
+ if (lines[i].includes("{")) { open = i; break; }
66
+ }
67
+ if (open < 0) return null;
68
+ const base = dm.start[open];
69
+ const close = dm.end[open] === base
70
+ ? open
71
+ : (() => {
72
+ for (let i = open + 1; i < lines.length; i++) if (dm.end[i] === base) return i;
73
+ return -1;
74
+ })();
75
+ if (close < 0) return null;
76
+ return { open, close, start: open + 1, end: close, depth: base + 1 };
77
+ }
78
+
79
+ function findMember(lines, dm, range, key) {
80
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
+ const re = new RegExp(`^\\s*"${escaped}"\\s*:`);
82
+ for (let i = range.start; i < range.end; i++) {
83
+ if (dm.start[i] === range.depth && re.test(lines[i])) return i;
84
+ }
85
+ return -1;
86
+ }
87
+
88
+ function objectRange(lines, dm, path) {
89
+ let current = rootRange(lines, dm);
90
+ if (!current) return null;
91
+ for (const key of path) {
92
+ const idx = findMember(lines, dm, current, key);
93
+ if (idx < 0) return null;
94
+ let open = -1;
95
+ for (let i = idx; i < current.end; i++) {
96
+ if (lines[i].includes("{")) { open = i; break; }
97
+ }
98
+ if (open < 0) return null;
99
+ const base = dm.start[idx];
100
+ let close = -1;
101
+ if (dm.end[open] === base) close = open;
102
+ else for (let i = open + 1; i < current.end; i++) if (dm.end[i] === base) { close = i; break; }
103
+ if (close < 0) return null;
104
+ current = { open, close, start: open + 1, end: close, depth: base + 1 };
105
+ }
106
+ return current;
107
+ }
108
+
109
+ function indent(level) { return " ".repeat(level); }
110
+
111
+ function lastContentLine(lines, range) {
112
+ for (let i = range.end - 1; i >= range.start; i--) {
113
+ if (lines[i].trim() !== "") return i;
114
+ }
115
+ return -1;
116
+ }
117
+
118
+ function ensureComma(lines, range) {
119
+ const last = lastContentLine(lines, range);
120
+ if (last >= 0 && !/,\s*$/.test(lines[last])) lines[last] += ",";
121
+ }
122
+
123
+ function expandEmptyObject(lines, range) {
124
+ if (range.open !== range.close) return lines;
125
+ const line = lines[range.open];
126
+ const openIdx = line.indexOf("{");
127
+ const closeIdx = line.lastIndexOf("}");
128
+ if (openIdx < 0 || closeIdx < openIdx) return lines;
129
+ const head = line.slice(0, openIdx + 1);
130
+ const tail = line.slice(closeIdx + 1);
131
+ const level = range.depth - 1;
132
+ lines[range.open] = head;
133
+ lines.splice(range.open + 1, 0, `${indent(level)}}${tail}`);
134
+ return lines;
135
+ }
136
+
137
+ function insertObject(lines, parent, key) {
138
+ let dm = depthMaps(lines);
139
+ let range = objectRange(lines, dm, parent);
140
+ if (!range) return lines;
141
+ if (range.open === range.close) {
142
+ lines = expandEmptyObject(lines, range);
143
+ dm = depthMaps(lines);
144
+ range = objectRange(lines, dm, parent);
145
+ if (!range) return lines;
146
+ }
147
+ ensureComma(lines, range);
148
+ const level = range.depth;
149
+ const memberIndent = indent(level);
150
+ lines.splice(range.end, 0, `${memberIndent}"${key}": {`, `${memberIndent}}`);
151
+ return lines;
152
+ }
153
+
154
+ function ensureObject(lines, path) {
155
+ let dm = depthMaps(lines);
156
+ if (objectRange(lines, dm, path)) return lines;
157
+ if (path.length === 0) return ["{", "}"];
158
+ lines = ensureObject(lines, path.slice(0, -1));
159
+ lines = insertObject(lines, path.slice(0, -1), path[path.length - 1]);
160
+ return lines;
161
+ }
162
+
163
+ function setKeyInRange(lines, range, key, value) {
164
+ let dm = depthMaps(lines);
165
+ let idx = findMember(lines, dm, range, key);
166
+ const memberIndent = indent(range.depth);
167
+ if (idx >= 0) {
168
+ const m = lines[idx].match(/^(\s*"[^"]+"\s*:\s*)(.*?)(\s*,)?\s*$/);
169
+ const comma = m && m[3] ? "," : "";
170
+ lines[idx] = `${m ? m[1] : `${memberIndent}"${key}": `}${value}${comma}`;
171
+ return lines;
172
+ }
173
+ if (range.open === range.close) {
174
+ lines = expandEmptyObject(lines, range);
175
+ dm = depthMaps(lines);
176
+ range = { ...range, start: range.open + 1, end: range.open + 1, close: range.open + 1 };
177
+ }
178
+ ensureComma(lines, range);
179
+ lines.splice(range.end, 0, `${memberIndent}"${key}": ${value}`);
180
+ return lines;
181
+ }
182
+
183
+ export function readJsonKey(text, path) {
184
+ const lines = normalizeLines((text || "").split(/\r?\n/));
185
+ const dm = depthMaps(lines);
186
+ const range = objectRange(lines, dm, path.slice(0, -1));
187
+ if (!range) return null;
188
+ const idx = findMember(lines, dm, range, path[path.length - 1]);
189
+ if (idx < 0) return null;
190
+ const m = lines[idx].match(/^\s*"[^"]+"\s*:\s*(.*?)\s*,?\s*$/);
191
+ return m ? m[1] : null;
192
+ }
193
+
194
+ export function setJsonKey(text, path, value) {
195
+ let lines = text === "" ? ["{", "}"] : normalizeLines(text.replace(/\r?\n$/, "").split(/\r?\n/));
196
+ if (!rootRange(lines, depthMaps(lines))) lines = ["{", "}"];
197
+ lines = ensureObject(lines, path.slice(0, -1));
198
+ let dm = depthMaps(lines);
199
+ const range = objectRange(lines, dm, path.slice(0, -1));
200
+ if (!range) return text;
201
+ lines = setKeyInRange(lines, range, path[path.length - 1], value);
202
+ return lines.join("\n") + "\n";
203
+ }
204
+
205
+ export function removeJsonKey(text, path) {
206
+ const lines = text === "" ? [] : normalizeLines(text.replace(/\r?\n$/, "").split(/\r?\n/));
207
+ const dm = depthMaps(lines);
208
+ const range = objectRange(lines, dm, path.slice(0, -1));
209
+ if (!range) return { text, removed: false };
210
+ const idx = findMember(lines, dm, range, path[path.length - 1]);
211
+ if (idx < 0) return { text, removed: false };
212
+ lines.splice(idx, 1);
213
+ const dm2 = depthMaps(lines);
214
+ const range2 = objectRange(lines, dm2, path.slice(0, -1));
215
+ if (range2) {
216
+ const hasLater = (() => {
217
+ for (let i = idx; i < range2.end; i++) if (dm2.start[i] === range2.depth) return true;
218
+ return false;
219
+ })();
220
+ if (!hasLater) {
221
+ const prev = lastContentLine(lines, range2);
222
+ if (prev >= 0) lines[prev] = lines[prev].replace(/,\s*$/, "");
223
+ }
224
+ }
225
+ return { text: lines.join("\n") + (lines.length ? "\n" : ""), removed: true };
226
+ }
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
 
4
4
  export function npmrcPath(env = process.env, home = homedir()) {
5
5
  return env.NPM_CONFIG_USERCONFIG || join(home, ".npmrc");
@@ -19,6 +19,14 @@ export function pnpmRcPath(env = process.env, home = homedir(), platform = proce
19
19
  return join(home, ".config", "pnpm", "rc");
20
20
  }
21
21
 
22
+ // pnpm >= 11 reads pnpm-specific settings from config.yaml (camelCase) instead
23
+ // of the registry/auth-only rc file. Keep it next to the rc path so custom
24
+ // PMSEC_PNPM_CONFIG_FILE overrides remain self-contained.
25
+ export function pnpmConfigPath(env = process.env, home = homedir(), platform = process.platform) {
26
+ if (env.PMSEC_PNPM_CONFIG_YAML) return env.PMSEC_PNPM_CONFIG_YAML;
27
+ return join(dirname(pnpmRcPath(env, home, platform)), "config.yaml");
28
+ }
29
+
22
30
  export function uvConfigPath(env = process.env, home = homedir(), platform = process.platform) {
23
31
  if (env.UV_CONFIG_FILE) return env.UV_CONFIG_FILE;
24
32
  if (platform === "win32") {
@@ -70,3 +78,18 @@ export function aubeConfigPath(env = process.env, home = homedir(), platform = p
70
78
  const base = env.XDG_CONFIG_HOME || join(home, ".config");
71
79
  return join(base, "aube", "config.toml");
72
80
  }
81
+
82
+ // Composer keeps its global config in $COMPOSER_HOME/config.json. The default
83
+ // COMPOSER_HOME follows platform conventions (XDG on Unix, Application Support
84
+ // on macOS, APPDATA on Windows), matching Composer's own resolution. An
85
+ // explicit XDG_CONFIG_HOME wins on every non-Windows platform, including macOS.
86
+ export function composerConfigPath(env = process.env, home = homedir(), platform = process.platform) {
87
+ if (env.COMPOSER_HOME) return join(env.COMPOSER_HOME, "config.json");
88
+ if (platform === "win32") {
89
+ const base = env.APPDATA || join(home, "AppData", "Roaming");
90
+ return join(base, "Composer", "config.json");
91
+ }
92
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "composer", "config.json");
93
+ if (platform === "darwin") return join(home, "Library", "Application Support", "composer", "config.json");
94
+ return join(home, ".config", "composer", "config.json");
95
+ }