pmsec 2026.11.2 → 2026.11.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmsec",
3
- "version": "2026.11.2",
3
+ "version": "2026.11.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/util/io.mjs CHANGED
@@ -6,7 +6,8 @@ import { randomBytes } from "node:crypto";
6
6
  import { homedir } from "node:os";
7
7
 
8
8
  export async function readSafe(path) {
9
- try { return await readFile(path, "utf8"); }
9
+ // Editors on Windows may prepend a BOM; it would hide the first header or key.
10
+ try { return (await readFile(path, "utf8")).replace(/^\uFEFF/, ""); }
10
11
  catch (e) { if (e.code === "ENOENT") return ""; throw e; }
11
12
  }
12
13
 
@@ -12,14 +12,17 @@ function headerName(line) {
12
12
  return m ? m[1].split(".").map(p => unquote(p.trim())).join(".") : null;
13
13
  }
14
14
 
15
+ // YAML nests by indentation, so only unindented `key:` lines are top-level.
16
+ function indentRe(sep) { return sep === ":" ? "^" : String.raw`^\s*`; }
17
+
15
18
  function entryKey(line, sep) {
16
- const m = line.match(new RegExp(String.raw`^\s*${KEY}\s*${sep}`));
19
+ const m = line.match(new RegExp(String.raw`${indentRe(sep)}${KEY}\s*${sep}`));
17
20
  return m ? unquote(m[1]) : null;
18
21
  }
19
22
 
20
23
  // Value after the separator with any trailing comment removed.
21
24
  function entryValue(line, sep) {
22
- const m = line.match(new RegExp(String.raw`^\s*${KEY}\s*${sep}\s*(.*?)\s*$`));
25
+ const m = line.match(new RegExp(String.raw`${indentRe(sep)}${KEY}\s*${sep}\s*(.*?)\s*$`));
23
26
  if (!m) return null;
24
27
  const v = m[2];
25
28
  const close = { '"': '"', "'": "'", "{": "}" }[v[0]];
@@ -30,6 +33,20 @@ function entryValue(line, sep) {
30
33
  return v.replace(/\s+#.*$/, "");
31
34
  }
32
35
 
36
+ // Copy of `lines` with the body of TOML multi-line strings blanked, so text
37
+ // inside them is never taken for a header or key. Indices stay aligned.
38
+ function scanView(lines) {
39
+ let open = null;
40
+ return lines.map(line => {
41
+ const inside = open !== null;
42
+ for (const m of line.matchAll(/"""|'''/g)) {
43
+ if (open === null) open = m[0];
44
+ else if (open === m[0]) open = null;
45
+ }
46
+ return inside ? "" : line;
47
+ });
48
+ }
49
+
33
50
  function rangeForSection(lines, section) {
34
51
  if (!section) {
35
52
  for (let i = 0; i < lines.length; i++) if (SECTION.test(lines[i])) return [0, i];
@@ -54,7 +71,17 @@ function indexOfKey(lines, range, key, sep) {
54
71
 
55
72
  // TOML forbids `a.b = x` under [s] when [s.a] exists or `a` is an inline table,
56
73
  // so target the subtable, or report the inline-table line to edit in place.
74
+ // A table defined at the root through dotted keys (`s.k = v`) or an inline
75
+ // table (`s = { ... }`) cannot also get a [s] header, so keep editing it there.
57
76
  function resolve(lines, key, sep, section) {
77
+ if (section && sep === "=" && rangeForSection(lines, section) === null) {
78
+ const [, end] = rangeForSection(lines, null);
79
+ for (let i = 0; i < end; i++) {
80
+ const k = entryKey(lines[i], sep) || "";
81
+ if (k.startsWith(`${section}.`)) return { key: `${section}.${key}`, section: null };
82
+ if (k === section && (entryValue(lines[i], sep) || "").startsWith("{")) return { key, section: null, inline: i, head: section };
83
+ }
84
+ }
58
85
  if (section && sep === "=" && key.includes(".")) {
59
86
  const dot = key.indexOf(".");
60
87
  const head = key.slice(0, dot), rest = key.slice(dot + 1);
@@ -87,7 +114,7 @@ function indexOfFirstSection(lines) {
87
114
 
88
115
  export function readKey(text, key, opts = {}) {
89
116
  const sep = opts.sep || "=";
90
- const lines = text.split(/\r?\n/);
117
+ const lines = scanView(text.split(/\r?\n/));
91
118
  const r = resolve(lines, key, sep, opts.section || null);
92
119
  if (r.inline !== undefined) {
93
120
  const pair = inlinePairs(lines[r.inline]).find(p => entryKey(p, sep) === r.key);
@@ -103,7 +130,8 @@ export function setKey(text, key, valueLine, opts = {}) {
103
130
  const sep = opts.sep || "=";
104
131
  const trailing = text.endsWith("\n") || text === "";
105
132
  const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
106
- const r = resolve(lines, key, sep, opts.section || null);
133
+ const scan = scanView(lines);
134
+ const r = resolve(scan, key, sep, opts.section || null);
107
135
  const section = r.section;
108
136
  if (r.key !== key) valueLine = r.key + valueLine.slice(key.length);
109
137
  if (r.inline !== undefined) {
@@ -114,19 +142,21 @@ export function setKey(text, key, valueLine, opts = {}) {
114
142
  return lines.join("\n") + "\n";
115
143
  }
116
144
  key = r.key;
117
- let range = rangeForSection(lines, section);
145
+ let range = rangeForSection(scan, section);
118
146
  if (range === null) {
119
147
  if (lines.length && lines[lines.length - 1] !== "") lines.push("");
120
148
  lines.push(`[${section}]`, valueLine);
121
149
  } else {
122
- const idx = indexOfKey(lines, range, key, sep);
150
+ const idx = indexOfKey(scan, range, key, sep);
123
151
  if (idx >= 0) {
124
152
  lines[idx] = valueLine;
125
153
  } else if (section) {
126
154
  lines.splice(range[0], 0, valueLine);
127
155
  } else {
128
- const firstSec = indexOfFirstSection(lines);
156
+ const firstSec = indexOfFirstSection(scan);
157
+ // Reuse the blank line that already separates the root keys from the first table.
129
158
  if (firstSec < 0) lines.push(valueLine);
159
+ else if (firstSec > 0 && lines[firstSec - 1] === "") lines.splice(firstSec - 1, 0, valueLine);
130
160
  else lines.splice(firstSec, 0, valueLine, "");
131
161
  }
132
162
  }
@@ -137,7 +167,8 @@ export function removeKey(text, key, opts = {}) {
137
167
  const sep = opts.sep || "=";
138
168
  const trailing = text.endsWith("\n");
139
169
  const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
140
- const r = resolve(lines, key, sep, opts.section || null);
170
+ const scan = scanView(lines);
171
+ const r = resolve(scan, key, sep, opts.section || null);
141
172
  if (r.inline !== undefined) {
142
173
  const pairs = inlinePairs(lines[r.inline]);
143
174
  const kept = pairs.filter(p => entryKey(p, sep) !== r.key);
@@ -145,9 +176,9 @@ export function removeKey(text, key, opts = {}) {
145
176
  lines[r.inline] = inlineLine(lines[r.inline], r.head, kept);
146
177
  return { text: lines.join("\n") + (trailing ? "\n" : ""), removed: true };
147
178
  }
148
- const range = rangeForSection(lines, r.section);
179
+ const range = rangeForSection(scan, r.section);
149
180
  if (range === null) return { text, removed: false };
150
- const idx = indexOfKey(lines, range, r.key, sep);
181
+ const idx = indexOfKey(scan, range, r.key, sep);
151
182
  if (idx < 0) return { text, removed: false };
152
183
  lines.splice(idx, 1);
153
184
  if (idx < lines.length && lines[idx] === "") lines.splice(idx, 1);