pmsec 2026.11.1 → 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.1",
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
 
@@ -1,9 +1,50 @@
1
- const SECTION = /^\s*\[[^\]]+\]\s*$/;
1
+ // TOML tables may carry a trailing comment, inner whitespace, quoted names, or be [[arrays]].
2
+ const SECTION = /^\s*\[\[?[^\[\]]+\]\]?\s*(#.*)?$/;
3
+ const HEADER = /^\s*\[([^\[\]]+)\]\s*(#.*)?$/;
4
+ const KEY = String.raw`("[^"]*"|'[^']*'|[A-Za-z0-9_.\-]+)`;
5
+
6
+ function unquote(s) {
7
+ return /^(".*"|'.*')$/.test(s) ? s.slice(1, -1) : s;
8
+ }
9
+
10
+ function headerName(line) {
11
+ const m = line.match(HEADER);
12
+ return m ? m[1].split(".").map(p => unquote(p.trim())).join(".") : null;
13
+ }
14
+
15
+ // YAML nests by indentation, so only unindented `key:` lines are top-level.
16
+ function indentRe(sep) { return sep === ":" ? "^" : String.raw`^\s*`; }
2
17
 
3
18
  function entryKey(line, sep) {
4
- const re = sep === ":" ? /^\s*([A-Za-z0-9_.\-]+)\s*:/ : /^\s*([A-Za-z0-9_.\-]+)\s*=/;
5
- const m = line.match(re);
6
- return m ? m[1] : null;
19
+ const m = line.match(new RegExp(String.raw`${indentRe(sep)}${KEY}\s*${sep}`));
20
+ return m ? unquote(m[1]) : null;
21
+ }
22
+
23
+ // Value after the separator with any trailing comment removed.
24
+ function entryValue(line, sep) {
25
+ const m = line.match(new RegExp(String.raw`${indentRe(sep)}${KEY}\s*${sep}\s*(.*?)\s*$`));
26
+ if (!m) return null;
27
+ const v = m[2];
28
+ const close = { '"': '"', "'": "'", "{": "}" }[v[0]];
29
+ if (close) {
30
+ const end = v.indexOf(close, 1);
31
+ if (end >= 0) return v.slice(0, end + 1);
32
+ }
33
+ return v.replace(/\s+#.*$/, "");
34
+ }
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
+ });
7
48
  }
8
49
 
9
50
  function rangeForSection(lines, section) {
@@ -11,10 +52,9 @@ function rangeForSection(lines, section) {
11
52
  for (let i = 0; i < lines.length; i++) if (SECTION.test(lines[i])) return [0, i];
12
53
  return [0, lines.length];
13
54
  }
14
- const header = `[${section}]`;
15
55
  let start = -1;
16
56
  for (let i = 0; i < lines.length; i++) {
17
- if (lines[i].trim() === header) { start = i + 1; break; }
57
+ if (headerName(lines[i]) === section) { start = i + 1; break; }
18
58
  }
19
59
  if (start < 0) return null;
20
60
  let end = lines.length;
@@ -29,6 +69,44 @@ function indexOfKey(lines, range, key, sep) {
29
69
  return -1;
30
70
  }
31
71
 
72
+ // TOML forbids `a.b = x` under [s] when [s.a] exists or `a` is an inline table,
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.
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
+ }
85
+ if (section && sep === "=" && key.includes(".")) {
86
+ const dot = key.indexOf(".");
87
+ const head = key.slice(0, dot), rest = key.slice(dot + 1);
88
+ const sub = `${section}.${head}`;
89
+ if (rangeForSection(lines, sub) !== null) return { key: rest, section: sub };
90
+ const range = rangeForSection(lines, section);
91
+ if (range !== null) {
92
+ const i = indexOfKey(lines, range, head, sep);
93
+ if (i >= 0 && (entryValue(lines[i], sep) || "").startsWith("{")) return { key: rest, section, inline: i, head };
94
+ }
95
+ }
96
+ return { key, section };
97
+ }
98
+
99
+ function inlinePairs(line) {
100
+ const v = entryValue(line, "=");
101
+ const body = v.slice(1, v.endsWith("}") ? -1 : undefined).trim();
102
+ return body === "" ? [] : body.split(",").map(p => p.trim()).filter(Boolean);
103
+ }
104
+
105
+ function inlineLine(line, head, pairs) {
106
+ const indent = line.match(/^\s*/)[0];
107
+ return `${indent}${head} = {${pairs.length ? ` ${pairs.join(", ")} ` : ""}}`;
108
+ }
109
+
32
110
  function indexOfFirstSection(lines) {
33
111
  for (let i = 0; i < lines.length; i++) if (SECTION.test(lines[i])) return i;
34
112
  return -1;
@@ -36,35 +114,49 @@ function indexOfFirstSection(lines) {
36
114
 
37
115
  export function readKey(text, key, opts = {}) {
38
116
  const sep = opts.sep || "=";
39
- const section = opts.section || null;
40
- const lines = text.split(/\r?\n/);
41
- const range = rangeForSection(lines, section);
117
+ const lines = scanView(text.split(/\r?\n/));
118
+ const r = resolve(lines, key, sep, opts.section || null);
119
+ if (r.inline !== undefined) {
120
+ const pair = inlinePairs(lines[r.inline]).find(p => entryKey(p, sep) === r.key);
121
+ return pair ? entryValue(pair, sep) : null;
122
+ }
123
+ const range = rangeForSection(lines, r.section);
42
124
  if (range === null) return null;
43
- const i = indexOfKey(lines, range, key, sep);
44
- if (i < 0) return null;
45
- const re = sep === ":" ? /^\s*[A-Za-z0-9_.\-]+\s*:\s*(.*?)\s*$/ : /^\s*[A-Za-z0-9_.\-]+\s*=\s*(.*?)\s*$/;
46
- const m = lines[i].match(re);
47
- return m ? m[1] : null;
125
+ const i = indexOfKey(lines, range, r.key, sep);
126
+ return i < 0 ? null : entryValue(lines[i], sep);
48
127
  }
49
128
 
50
129
  export function setKey(text, key, valueLine, opts = {}) {
51
130
  const sep = opts.sep || "=";
52
- const section = opts.section || null;
53
131
  const trailing = text.endsWith("\n") || text === "";
54
132
  const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
55
- let range = rangeForSection(lines, section);
133
+ const scan = scanView(lines);
134
+ const r = resolve(scan, key, sep, opts.section || null);
135
+ const section = r.section;
136
+ if (r.key !== key) valueLine = r.key + valueLine.slice(key.length);
137
+ if (r.inline !== undefined) {
138
+ const pairs = inlinePairs(lines[r.inline]);
139
+ const at = pairs.findIndex(p => entryKey(p, sep) === r.key);
140
+ if (at >= 0) pairs[at] = valueLine; else pairs.push(valueLine);
141
+ lines[r.inline] = inlineLine(lines[r.inline], r.head, pairs);
142
+ return lines.join("\n") + "\n";
143
+ }
144
+ key = r.key;
145
+ let range = rangeForSection(scan, section);
56
146
  if (range === null) {
57
147
  if (lines.length && lines[lines.length - 1] !== "") lines.push("");
58
148
  lines.push(`[${section}]`, valueLine);
59
149
  } else {
60
- const idx = indexOfKey(lines, range, key, sep);
150
+ const idx = indexOfKey(scan, range, key, sep);
61
151
  if (idx >= 0) {
62
152
  lines[idx] = valueLine;
63
153
  } else if (section) {
64
154
  lines.splice(range[0], 0, valueLine);
65
155
  } else {
66
- const firstSec = indexOfFirstSection(lines);
156
+ const firstSec = indexOfFirstSection(scan);
157
+ // Reuse the blank line that already separates the root keys from the first table.
67
158
  if (firstSec < 0) lines.push(valueLine);
159
+ else if (firstSec > 0 && lines[firstSec - 1] === "") lines.splice(firstSec - 1, 0, valueLine);
68
160
  else lines.splice(firstSec, 0, valueLine, "");
69
161
  }
70
162
  }
@@ -73,12 +165,20 @@ export function setKey(text, key, valueLine, opts = {}) {
73
165
 
74
166
  export function removeKey(text, key, opts = {}) {
75
167
  const sep = opts.sep || "=";
76
- const section = opts.section || null;
77
168
  const trailing = text.endsWith("\n");
78
169
  const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
79
- const range = rangeForSection(lines, section);
170
+ const scan = scanView(lines);
171
+ const r = resolve(scan, key, sep, opts.section || null);
172
+ if (r.inline !== undefined) {
173
+ const pairs = inlinePairs(lines[r.inline]);
174
+ const kept = pairs.filter(p => entryKey(p, sep) !== r.key);
175
+ if (kept.length === pairs.length) return { text, removed: false };
176
+ lines[r.inline] = inlineLine(lines[r.inline], r.head, kept);
177
+ return { text: lines.join("\n") + (trailing ? "\n" : ""), removed: true };
178
+ }
179
+ const range = rangeForSection(scan, r.section);
80
180
  if (range === null) return { text, removed: false };
81
- const idx = indexOfKey(lines, range, key, sep);
181
+ const idx = indexOfKey(scan, range, r.key, sep);
82
182
  if (idx < 0) return { text, removed: false };
83
183
  lines.splice(idx, 1);
84
184
  if (idx < lines.length && lines[idx] === "") lines.splice(idx, 1);