@delorenj/pjangler 1.3.0 → 1.4.2

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.
Files changed (43) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +92 -0
  3. package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
  4. package/dist/assets/project-notebook-skill/SKILL.md +64 -0
  5. package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
  6. package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
  7. package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
  8. package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
  9. package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
  10. package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
  11. package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
  12. package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
  13. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
  14. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
  15. package/dist/index.js +11307 -2809
  16. package/dist/mcp-server.js +9637 -2094
  17. package/dist/prompt.js +404 -0
  18. package/package.json +8 -5
  19. package/templates/commonproject/copier.yml +19 -5
  20. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  21. package/templates/commonproject/template/.mise/scripts/provision-packs.py +74 -52
  22. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  23. package/templates/commonproject/template/mise.toml.jinja +12 -6
  24. package/templates/hermes-agent/copier.yml +8 -11
  25. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  26. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  27. package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
  28. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  29. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
  30. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  31. package/templates/hermes-agent/template/.scripts/70-systemd.sh +77 -43
  32. package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
  33. package/templates/hermes-agent/template/.scripts/_lib.sh +116 -16
  34. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  35. package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
  36. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  37. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +761 -0
  38. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  39. package/templates/hermes-agent/template/.scripts/providers/plane.sh +19 -3
  40. package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
  41. package/templates/hermes-agent/template/hermes.jinja +20 -8
  42. package/templates/hermes-agent/template/momo.jinja +177 -0
  43. package/templates/hermes-agent/template/role.yaml.jinja +19 -19
@@ -0,0 +1,761 @@
1
+ #!/usr/bin/env python3
2
+ """Parse the supported fleet.env assignment grammar without executing it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ctypes
7
+ import errno
8
+ import hashlib
9
+ import re
10
+ import os
11
+ import stat
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Mapping, NamedTuple
16
+
17
+
18
+ HEADER = b"PJANGLER_FLEET_ENV_V1"
19
+ FOOTER = b"PJANGLER_FLEET_ENV_END"
20
+ NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
21
+ ASSIGNMENT = re.compile(r"[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=")
22
+ LEGACY_EXPANSION_NAME = "HERMES_FLEET_HOME"
23
+ LEGACY_EXPANSION = re.compile(
24
+ r"\$(?:\{HERMES_FLEET_HOME\}|HERMES_FLEET_HOME(?![A-Za-z0-9_]))"
25
+ )
26
+ UNICODE_ERROR = "fleet environment parse error: input and values must be valid UTF-8 Unicode"
27
+ INITIAL_DOCUMENT = (
28
+ "# Hermes fleet source of truth.\n"
29
+ "# All generated wrappers and provisioning scripts read this file.\n"
30
+ )
31
+
32
+
33
+ class FleetEnvParseError(ValueError):
34
+ def __init__(self, line: int, message: str) -> None:
35
+ super().__init__(f"line {line}: {message}")
36
+
37
+
38
+ class FleetEnvRecoveryError(OSError):
39
+ """A concurrent destination was preserved after rollback could not finish."""
40
+
41
+ def __init__(self, recovery_path: Path) -> None:
42
+ super().__init__(
43
+ "fleet environment destination changed and recovery failed; "
44
+ "concurrent data preserved"
45
+ )
46
+ self.recovery_path = recovery_path
47
+
48
+
49
+ class ParsedRecord(NamedTuple):
50
+ key: str
51
+ value: str
52
+ start: int
53
+ end: int
54
+
55
+
56
+ def line_number(text: str, offset: int) -> int:
57
+ return text.count("\n", 0, offset) + 1
58
+
59
+
60
+ def line_end(text: str, offset: int) -> int:
61
+ end = text.find("\n", offset)
62
+ return len(text) if end < 0 else end
63
+
64
+
65
+ def next_line(text: str, end: int) -> int:
66
+ return end if end == len(text) else end + 1
67
+
68
+
69
+ def validate_suffix(text: str, offset: int, origin: int) -> int:
70
+ end = line_end(text, offset)
71
+ suffix = text[offset:end]
72
+ if not re.fullmatch(r"[ \t]*(?:#.*)?", suffix):
73
+ raise FleetEnvParseError(
74
+ line_number(text, origin), "unexpected content after quoted value"
75
+ )
76
+ return next_line(text, end)
77
+
78
+
79
+ def parse_single_quoted(text: str, offset: int) -> tuple[str, int]:
80
+ origin = offset
81
+ closing = text.find("'", offset + 1)
82
+ if closing < 0:
83
+ raise FleetEnvParseError(line_number(text, origin), "unterminated single quote")
84
+ return text[offset + 1 : closing], validate_suffix(text, closing + 1, origin)
85
+
86
+
87
+ def parse_double_quoted(text: str, offset: int) -> tuple[str, int]:
88
+ origin = offset
89
+ cursor = offset + 1
90
+ value: list[str] = []
91
+ while cursor < len(text):
92
+ char = text[cursor]
93
+ if char == '"':
94
+ return "".join(value), validate_suffix(text, cursor + 1, origin)
95
+ if char in {"$", "`"}:
96
+ raise FleetEnvParseError(
97
+ line_number(text, cursor),
98
+ "dynamic expansion is not supported in fleet.env",
99
+ )
100
+ if char != "\\":
101
+ value.append(char)
102
+ cursor += 1
103
+ continue
104
+ if cursor + 1 >= len(text):
105
+ raise FleetEnvParseError(line_number(text, cursor), "unterminated escape")
106
+ escaped = text[cursor + 1]
107
+ if escaped == "\n":
108
+ cursor += 2
109
+ continue
110
+ if escaped in {'"', "\\", "$", "`"}:
111
+ value.append(escaped)
112
+ else:
113
+ value.extend(("\\", escaped))
114
+ cursor += 2
115
+ raise FleetEnvParseError(line_number(text, origin), "unterminated double quote")
116
+
117
+
118
+ ANSI_ESCAPES = {
119
+ "a": "\a",
120
+ "b": "\b",
121
+ "e": "\x1b",
122
+ "E": "\x1b",
123
+ "f": "\f",
124
+ "n": "\n",
125
+ "r": "\r",
126
+ "t": "\t",
127
+ "v": "\v",
128
+ "\\": "\\",
129
+ "'": "'",
130
+ '"': '"',
131
+ "?": "?",
132
+ }
133
+
134
+
135
+ def parse_hex_escape(text: str, offset: int, maximum: int) -> tuple[str, int]:
136
+ cursor = offset
137
+ while (
138
+ cursor < len(text)
139
+ and cursor - offset < maximum
140
+ and text[cursor] in "0123456789abcdefABCDEF"
141
+ ):
142
+ cursor += 1
143
+ if cursor == offset:
144
+ raise FleetEnvParseError(line_number(text, offset), "empty hexadecimal escape")
145
+ return chr(int(text[offset:cursor], 16)), cursor
146
+
147
+
148
+ def parse_ansi_c_quoted(text: str, offset: int) -> tuple[str, int]:
149
+ origin = offset
150
+ cursor = offset + 2
151
+ value: list[str] = []
152
+ while cursor < len(text):
153
+ char = text[cursor]
154
+ if char == "'":
155
+ parsed = "".join(value)
156
+ if "\0" in parsed:
157
+ raise FleetEnvParseError(
158
+ line_number(text, origin), "NUL is not allowed"
159
+ )
160
+ return parsed, validate_suffix(text, cursor + 1, origin)
161
+ if char != "\\":
162
+ value.append(char)
163
+ cursor += 1
164
+ continue
165
+ if cursor + 1 >= len(text):
166
+ raise FleetEnvParseError(line_number(text, cursor), "unterminated escape")
167
+ escaped = text[cursor + 1]
168
+ if escaped == "\n":
169
+ cursor += 2
170
+ continue
171
+ if escaped in ANSI_ESCAPES:
172
+ value.append(ANSI_ESCAPES[escaped])
173
+ cursor += 2
174
+ continue
175
+ if escaped == "x":
176
+ decoded, cursor = parse_hex_escape(text, cursor + 2, 2)
177
+ value.append(decoded)
178
+ continue
179
+ if escaped in {"u", "U"}:
180
+ width = 4 if escaped == "u" else 8
181
+ start = cursor + 2
182
+ digits = text[start : start + width]
183
+ if len(digits) != width or any(
184
+ c not in "0123456789abcdefABCDEF" for c in digits
185
+ ):
186
+ raise FleetEnvParseError(
187
+ line_number(text, cursor), "invalid Unicode escape"
188
+ )
189
+ try:
190
+ value.append(chr(int(digits, 16)))
191
+ except ValueError as error:
192
+ raise FleetEnvParseError(
193
+ line_number(text, cursor), "invalid Unicode code point"
194
+ ) from error
195
+ cursor = start + width
196
+ continue
197
+ if escaped in "01234567":
198
+ start = cursor + 1
199
+ end = start
200
+ while end < len(text) and end - start < 3 and text[end] in "01234567":
201
+ end += 1
202
+ value.append(chr(int(text[start:end], 8)))
203
+ cursor = end
204
+ continue
205
+ value.extend(("\\", escaped))
206
+ cursor += 2
207
+ raise FleetEnvParseError(line_number(text, origin), "unterminated ANSI-C quote")
208
+
209
+
210
+ def parse_unquoted(
211
+ text: str,
212
+ offset: int,
213
+ expansion_values: Mapping[str, str],
214
+ ) -> tuple[str, int]:
215
+ end = line_end(text, offset)
216
+ raw = text[offset:end]
217
+ comment = re.search(r"[ \t]+#", raw)
218
+ if comment:
219
+ raw = raw[: comment.start()]
220
+ if not raw:
221
+ return "", next_line(text, end)
222
+ if re.search(r"[ \t;&|<>()`\\'\"]", raw):
223
+ raise FleetEnvParseError(
224
+ line_number(text, offset), "unquoted value contains shell syntax"
225
+ )
226
+ if "$" not in raw:
227
+ return raw, next_line(text, end)
228
+
229
+ # Compatibility is intentionally narrow: exactly one allowed token must be
230
+ # the first byte, followed only by the already-validated literal suffix.
231
+ # Prefixes and repeated/partial tokens would turn this into a general
232
+ # expansion language and are therefore rejected.
233
+ match = LEGACY_EXPANSION.match(raw)
234
+ if match is None or match.start() != 0:
235
+ raise FleetEnvParseError(
236
+ line_number(text, offset),
237
+ "dynamic expansion is not supported in fleet.env",
238
+ )
239
+ suffix = raw[match.end() :]
240
+ if "$" in suffix:
241
+ raise FleetEnvParseError(
242
+ line_number(text, offset),
243
+ "dynamic expansion is not supported in fleet.env",
244
+ )
245
+ value = expansion_values.get(LEGACY_EXPANSION_NAME)
246
+ if value is None:
247
+ raise FleetEnvParseError(
248
+ line_number(text, offset),
249
+ "legacy HERMES_FLEET_HOME expansion has no value",
250
+ )
251
+ return value + suffix, next_line(text, end)
252
+
253
+
254
+ def parse_value(
255
+ text: str,
256
+ offset: int,
257
+ expansion_values: Mapping[str, str],
258
+ ) -> tuple[str, int]:
259
+ if offset >= len(text) or text[offset] == "\n":
260
+ end = line_end(text, offset)
261
+ return "", next_line(text, end)
262
+ if text.startswith("$'", offset):
263
+ return parse_ansi_c_quoted(text, offset)
264
+ if text[offset] == "'":
265
+ return parse_single_quoted(text, offset)
266
+ if text[offset] == '"':
267
+ return parse_double_quoted(text, offset)
268
+ return parse_unquoted(text, offset, expansion_values)
269
+
270
+
271
+ def normalize_text(text: str) -> str:
272
+ if "\0" in text:
273
+ raise FleetEnvParseError(1, "NUL is not allowed")
274
+ if "\r" in text:
275
+ text = text.replace("\r\n", "\n")
276
+ if "\r" in text:
277
+ raise FleetEnvParseError(1, "bare carriage return is not supported")
278
+ return text
279
+
280
+
281
+ def validate_unicode(value: str) -> None:
282
+ for char in value:
283
+ codepoint = ord(char)
284
+ if 0xD800 <= codepoint <= 0xDFFF:
285
+ raise UnicodeError("surrogate code point")
286
+ if 0xFDD0 <= codepoint <= 0xFDEF or codepoint & 0xFFFE == 0xFFFE:
287
+ raise UnicodeError("Unicode noncharacter")
288
+ value.encode("utf-8", errors="strict")
289
+
290
+
291
+ def parse_document(
292
+ text: str,
293
+ environment: Mapping[str, str] | None = None,
294
+ ) -> tuple[str, list[ParsedRecord]]:
295
+ text = normalize_text(text)
296
+ inherited = environment if environment is not None else os.environ
297
+ expansion_values: dict[str, str] = {}
298
+ caller_has_fleet_home = LEGACY_EXPANSION_NAME in inherited
299
+ if caller_has_fleet_home:
300
+ expansion_values[LEGACY_EXPANSION_NAME] = inherited[LEGACY_EXPANSION_NAME]
301
+
302
+ cursor = 0
303
+ records: list[ParsedRecord] = []
304
+ seen: set[str] = set()
305
+ while cursor < len(text):
306
+ origin = cursor
307
+ end = line_end(text, cursor)
308
+ physical = text[cursor:end]
309
+ if not physical.strip() or physical.lstrip().startswith("#"):
310
+ cursor = next_line(text, end)
311
+ continue
312
+ match = ASSIGNMENT.match(text, cursor)
313
+ if not match or match.end() > end:
314
+ raise FleetEnvParseError(
315
+ line_number(text, cursor), "expected KEY=value or export KEY=value"
316
+ )
317
+ key = match.group(1)
318
+ if not NAME.fullmatch(key):
319
+ raise FleetEnvParseError(line_number(text, cursor), "invalid variable name")
320
+ if key in seen:
321
+ raise FleetEnvParseError(
322
+ line_number(text, cursor), f"duplicate variable {key}"
323
+ )
324
+ value, cursor = parse_value(text, match.end(), expansion_values)
325
+ validate_unicode(value)
326
+ seen.add(key)
327
+ records.append(ParsedRecord(key, value, origin, cursor))
328
+ if key == LEGACY_EXPANSION_NAME and not caller_has_fleet_home:
329
+ expansion_values[key] = value
330
+ return text, records
331
+
332
+
333
+ def parse(
334
+ text: str,
335
+ environment: Mapping[str, str] | None = None,
336
+ ) -> list[tuple[str, str]]:
337
+ _, records = parse_document(text, environment)
338
+ return [(record.key, record.value) for record in records]
339
+
340
+
341
+ def serialize_literal(value: str) -> str:
342
+ validate_unicode(value)
343
+ escaped: list[str] = []
344
+ simple_escapes = {
345
+ "\\": r"\\",
346
+ "'": r"\'",
347
+ "\a": r"\a",
348
+ "\b": r"\b",
349
+ "\x1b": r"\e",
350
+ "\f": r"\f",
351
+ "\n": r"\n",
352
+ "\r": r"\r",
353
+ "\t": r"\t",
354
+ "\v": r"\v",
355
+ }
356
+ for char in value:
357
+ if char in simple_escapes:
358
+ escaped.append(simple_escapes[char])
359
+ elif ord(char) < 0x20 or ord(char) == 0x7F:
360
+ escaped.append(f"\\x{ord(char):02x}")
361
+ else:
362
+ escaped.append(char)
363
+ return "$'" + "".join(escaped) + "'"
364
+
365
+
366
+ def serialize_systemd_value(value: str) -> str:
367
+ """Return one lossless, injection-safe systemd.syntax scalar."""
368
+ validate_unicode(value)
369
+ if "\0" in value:
370
+ raise FleetEnvParseError(1, "NUL is not allowed in a systemd value")
371
+ if "\r" in value or "\n" in value:
372
+ raise FleetEnvParseError(1, "newline control characters are not allowed in a systemd value")
373
+ escaped: list[str] = []
374
+ for char in value:
375
+ if char == "\\":
376
+ escaped.append(r"\\")
377
+ elif char == '"':
378
+ escaped.append(r'\"')
379
+ elif char == "%":
380
+ # systemd specifiers are expanded after syntax unquoting. Doubling
381
+ # preserves a caller-provided percent byte as literal data.
382
+ escaped.append("%%")
383
+ elif char == "\t":
384
+ escaped.append(r"\t")
385
+ elif ord(char) < 0x20 or ord(char) == 0x7F:
386
+ escaped.append(f"\\x{ord(char):02x}")
387
+ else:
388
+ escaped.append(char)
389
+ return '"' + "".join(escaped) + '"'
390
+
391
+
392
+ def serialize_systemd_scalar(value: str) -> str:
393
+ """Return a scalar directive value without introducing literal quotes."""
394
+ validate_unicode(value)
395
+ if "\0" in value:
396
+ raise FleetEnvParseError(1, "NUL is not allowed in a systemd value")
397
+ if "\r" in value or "\n" in value:
398
+ raise FleetEnvParseError(1, "newline control characters are not allowed in a systemd value")
399
+ escaped: list[str] = []
400
+ for char in value:
401
+ if char == "\\":
402
+ escaped.append(r"\\")
403
+ elif char == "%":
404
+ escaped.append("%%")
405
+ elif char == "\t":
406
+ escaped.append(r"\t")
407
+ elif ord(char) < 0x20 or ord(char) == 0x7F:
408
+ escaped.append(f"\\x{ord(char):02x}")
409
+ else:
410
+ escaped.append(char)
411
+ return "".join(escaped)
412
+
413
+
414
+ def serialize_systemd_environment(name: str, value: str) -> str:
415
+ if not NAME.fullmatch(name):
416
+ raise FleetEnvParseError(1, "invalid systemd environment variable name")
417
+ return f"Environment={serialize_systemd_value(f'{name}={value}')}"
418
+
419
+
420
+ def serialize_systemd_exec_value(value: str) -> str:
421
+ """Quote one ExecStart token while suppressing systemd $/%% expansion."""
422
+ return serialize_systemd_value(value.replace("$", "$$"))
423
+
424
+
425
+ def read_regular_document(
426
+ path: Path,
427
+ *,
428
+ allow_missing: bool = False,
429
+ ) -> tuple[str, os.stat_result | None]:
430
+ try:
431
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
432
+ except FileNotFoundError:
433
+ if allow_missing:
434
+ return INITIAL_DOCUMENT, None
435
+ raise FleetEnvParseError(1, "fleet environment path is unavailable")
436
+ except OSError as error:
437
+ raise FleetEnvParseError(1, "fleet environment path must be a regular file") from error
438
+ try:
439
+ metadata = os.fstat(descriptor)
440
+ if not stat.S_ISREG(metadata.st_mode):
441
+ raise FleetEnvParseError(1, "fleet environment path must be a regular file")
442
+ with os.fdopen(descriptor, "rb", closefd=False) as stream:
443
+ content = stream.read()
444
+ after = os.fstat(descriptor)
445
+ if not _same_file_metadata(metadata, after):
446
+ raise FleetEnvParseError(1, "fleet environment changed while it was read")
447
+ return content.decode("utf-8"), after
448
+ finally:
449
+ os.close(descriptor)
450
+
451
+
452
+ def write_atomic_document(
453
+ path: Path,
454
+ content: str,
455
+ original: os.stat_result | None,
456
+ original_content: bytes | None = None,
457
+ ) -> None:
458
+ parent = path.parent
459
+ descriptor, temporary_name = tempfile.mkstemp(
460
+ prefix=f".{path.name}.",
461
+ dir=parent,
462
+ )
463
+ temporary = Path(temporary_name)
464
+ preserve_temporary = False
465
+ try:
466
+ desired_mode = stat.S_IMODE(original.st_mode) if original else 0o600
467
+ if original is not None:
468
+ # chown(2) may clear set-ID mode bits. Establish ownership first,
469
+ # then restore the exact original mode and attest both properties
470
+ # on the prepared inode before it can participate in an exchange.
471
+ # Permission failures are not an excuse to silently drift metadata.
472
+ os.fchown(descriptor, original.st_uid, original.st_gid)
473
+ encoded = content.encode("utf-8", errors="strict")
474
+ with os.fdopen(descriptor, "wb", closefd=False) as stream:
475
+ stream.write(encoded)
476
+ stream.flush()
477
+ # A content write can also clear set-ID bits. This is therefore the
478
+ # final metadata operation before attestation and atomic commit.
479
+ os.fchmod(descriptor, desired_mode)
480
+ prepared = os.fstat(descriptor)
481
+ if original is not None and (
482
+ prepared.st_uid != original.st_uid
483
+ or prepared.st_gid != original.st_gid
484
+ ):
485
+ raise OSError("fleet environment ownership could not be preserved")
486
+ if stat.S_IMODE(prepared.st_mode) != desired_mode:
487
+ raise OSError("fleet environment mode could not be preserved")
488
+ os.fsync(descriptor)
489
+ os.close(descriptor)
490
+ descriptor = -1
491
+
492
+ if original is None:
493
+ # link(2) supplies portable no-clobber creation. The temporary and
494
+ # destination share a directory/filesystem, so successful linking
495
+ # commits the fully-written inode without a check/use gap.
496
+ os.link(temporary, path, follow_symlinks=False)
497
+ temporary.unlink()
498
+ else:
499
+ # Linux renameat2(RENAME_EXCHANGE) swaps the prepared file and the
500
+ # current destination in one syscall. Only after that atomic claim
501
+ # do we inspect the displaced inode. A concurrent replacement is
502
+ # immediately exchanged back and reported, never overwritten.
503
+ # Platforms without atomic exchange fail closed before mutation;
504
+ # there is no portable conditional-replace primitive for this CAS.
505
+ _exchange_paths(temporary, path)
506
+ displaced = os.lstat(temporary)
507
+ if original_content is None or not _matches_file_snapshot(
508
+ temporary,
509
+ original,
510
+ original_content,
511
+ ):
512
+ try:
513
+ _exchange_paths(temporary, path)
514
+ except OSError as recovery_error:
515
+ # After the failed reverse exchange, `path` contains our
516
+ # prepared update and `temporary` contains the concurrent
517
+ # replacement. From this point onward the temporary name is
518
+ # data, not scratch: never let the generic finally cleanup
519
+ # unlink it. Give the displaced inode a deterministic,
520
+ # restrictive recovery name when the filesystem permits,
521
+ # otherwise retain the exact mkstemp name in exception
522
+ # metadata for operator recovery.
523
+ preserve_temporary = True
524
+ recovery_path = _preserve_displaced_recovery(
525
+ temporary,
526
+ path,
527
+ displaced,
528
+ )
529
+ raise FleetEnvRecoveryError(recovery_path) from recovery_error
530
+ raise OSError("fleet environment destination changed during update")
531
+ temporary.unlink()
532
+
533
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
534
+ directory_descriptor = os.open(parent, directory_flags)
535
+ try:
536
+ os.fsync(directory_descriptor)
537
+ finally:
538
+ os.close(directory_descriptor)
539
+ finally:
540
+ if descriptor >= 0:
541
+ os.close(descriptor)
542
+ if not preserve_temporary:
543
+ try:
544
+ temporary.unlink()
545
+ except FileNotFoundError:
546
+ pass
547
+
548
+
549
+ def _same_file_metadata(expected: os.stat_result, actual: os.stat_result) -> bool:
550
+ """Compare the stable identity, content-version, and security metadata."""
551
+ return (
552
+ stat.S_ISREG(actual.st_mode)
553
+ and (actual.st_dev, actual.st_ino) == (expected.st_dev, expected.st_ino)
554
+ and actual.st_mode == expected.st_mode
555
+ and actual.st_uid == expected.st_uid
556
+ and actual.st_gid == expected.st_gid
557
+ and actual.st_size == expected.st_size
558
+ and actual.st_mtime_ns == expected.st_mtime_ns
559
+ )
560
+
561
+
562
+ def _matches_file_snapshot(
563
+ path: Path,
564
+ expected: os.stat_result,
565
+ expected_content: bytes,
566
+ ) -> bool:
567
+ """Attest one displaced inode without following a replacement symlink."""
568
+ try:
569
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
570
+ except OSError:
571
+ return False
572
+ try:
573
+ before = os.fstat(descriptor)
574
+ chunks: list[bytes] = []
575
+ while True:
576
+ chunk = os.read(descriptor, 1024 * 1024)
577
+ if not chunk:
578
+ break
579
+ chunks.append(chunk)
580
+ after = os.fstat(descriptor)
581
+ finally:
582
+ os.close(descriptor)
583
+ return (
584
+ _same_file_metadata(expected, before)
585
+ and _same_file_metadata(before, after)
586
+ and b"".join(chunks) == expected_content
587
+ )
588
+
589
+
590
+ def _remove_recovery_temporary(path: Path) -> bool:
591
+ """Best-effort cleanup after a durable recovery link was established."""
592
+ try:
593
+ path.unlink()
594
+ except OSError:
595
+ return False
596
+ return True
597
+
598
+
599
+ def _preserve_displaced_recovery(
600
+ temporary: Path,
601
+ destination: Path,
602
+ displaced: os.stat_result,
603
+ ) -> Path:
604
+ """Keep a displaced concurrent inode reachable without overwriting data."""
605
+ if stat.S_ISREG(displaced.st_mode):
606
+ descriptor = os.open(temporary, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
607
+ try:
608
+ os.fchmod(descriptor, 0o600)
609
+ digest = hashlib.sha256()
610
+ while True:
611
+ chunk = os.read(descriptor, 1024 * 1024)
612
+ if not chunk:
613
+ break
614
+ digest.update(chunk)
615
+ finally:
616
+ os.close(descriptor)
617
+ fingerprint = digest.hexdigest()[:16]
618
+ else:
619
+ fingerprint = f"mode-{stat.S_IFMT(displaced.st_mode):x}"
620
+
621
+ recovery = destination.parent / (
622
+ f".{destination.name}.pjangler-recovery-"
623
+ f"{displaced.st_dev:x}-{displaced.st_ino:x}-{fingerprint}"
624
+ )
625
+ try:
626
+ os.link(temporary, recovery, follow_symlinks=False)
627
+ except FileExistsError:
628
+ existing = os.lstat(recovery)
629
+ if (existing.st_dev, existing.st_ino) != (displaced.st_dev, displaced.st_ino):
630
+ return temporary
631
+ except OSError:
632
+ return temporary
633
+ _remove_recovery_temporary(temporary)
634
+ return recovery
635
+
636
+
637
+ def _exchange_paths(left: Path, right: Path) -> None:
638
+ """Atomically exchange two paths, or fail without a portability downgrade."""
639
+ try:
640
+ renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
641
+ except AttributeError as error:
642
+ raise OSError(errno.ENOTSUP, "atomic path exchange is unavailable") from error
643
+ renameat2.argtypes = (
644
+ ctypes.c_int,
645
+ ctypes.c_char_p,
646
+ ctypes.c_int,
647
+ ctypes.c_char_p,
648
+ ctypes.c_uint,
649
+ )
650
+ renameat2.restype = ctypes.c_int
651
+ at_fdcwd = -100
652
+ rename_exchange = 2
653
+ if renameat2(
654
+ at_fdcwd,
655
+ os.fsencode(left),
656
+ at_fdcwd,
657
+ os.fsencode(right),
658
+ rename_exchange,
659
+ ) != 0:
660
+ code = ctypes.get_errno()
661
+ raise OSError(code, os.strerror(code))
662
+
663
+
664
+ def render_upsert(
665
+ text: str,
666
+ key: str,
667
+ value: str,
668
+ environment: Mapping[str, str] | None = None,
669
+ ) -> str:
670
+ if not NAME.fullmatch(key):
671
+ raise FleetEnvParseError(1, "invalid variable name")
672
+ normalized, records = parse_document(text, environment)
673
+ matches = [record for record in records if record.key == key]
674
+ if len(matches) > 1:
675
+ raise FleetEnvParseError(1, f"duplicate variable {key}")
676
+
677
+ replacement = f"{key}={serialize_literal(value)}\n"
678
+ if matches:
679
+ record = matches[0]
680
+ updated = normalized[: record.start] + replacement + normalized[record.end :]
681
+ else:
682
+ separator = "" if not normalized or normalized.endswith("\n") else "\n"
683
+ updated = normalized + separator + replacement
684
+
685
+ # Validate the complete prospective document before opening a temporary
686
+ # output. This prevents a malformed legacy record from being partially
687
+ # repaired or hidden by an otherwise valid upsert.
688
+ parse_document(updated, environment)
689
+ return updated
690
+
691
+
692
+ def atomic_upsert(path: Path, key: str, value: str) -> None:
693
+ text, original = read_regular_document(path, allow_missing=True)
694
+ updated = render_upsert(text, key, value)
695
+ original_content = text.encode("utf-8", errors="strict") if original is not None else None
696
+ write_atomic_document(path, updated, original, original_content)
697
+
698
+
699
+ def emit_records(records: list[tuple[str, str]]) -> None:
700
+ framed = [HEADER]
701
+ framed.extend(f"{key}={value}".encode("utf-8") for key, value in records)
702
+ framed.extend((FOOTER, b"", b""))
703
+ sys.stdout.buffer.write(b"\0".join(framed))
704
+
705
+
706
+ def main() -> int:
707
+ parse_mode = len(sys.argv) == 2
708
+ upsert_mode = len(sys.argv) == 5 and sys.argv[1] == "--upsert"
709
+ systemd_value_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-value"
710
+ systemd_scalar_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-scalar"
711
+ systemd_exec_value_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-exec-value"
712
+ systemd_environment_mode = len(sys.argv) == 4 and sys.argv[1] == "--systemd-environment"
713
+ if not parse_mode and not upsert_mode and not systemd_value_mode and not systemd_scalar_mode and not systemd_exec_value_mode and not systemd_environment_mode:
714
+ print(
715
+ "usage: parse-fleet-env.py PATH | --upsert PATH KEY VALUE | "
716
+ "--systemd-value VALUE | --systemd-scalar VALUE | "
717
+ "--systemd-exec-value VALUE | --systemd-environment NAME VALUE",
718
+ file=sys.stderr,
719
+ )
720
+ return 2
721
+ try:
722
+ if systemd_value_mode:
723
+ print(serialize_systemd_value(sys.argv[2]), end="")
724
+ return 0
725
+ if systemd_scalar_mode:
726
+ print(serialize_systemd_scalar(sys.argv[2]), end="")
727
+ return 0
728
+ if systemd_exec_value_mode:
729
+ print(serialize_systemd_exec_value(sys.argv[2]), end="")
730
+ return 0
731
+ if systemd_environment_mode:
732
+ print(serialize_systemd_environment(sys.argv[2], sys.argv[3]), end="")
733
+ return 0
734
+ if upsert_mode:
735
+ atomic_upsert(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
736
+ return 0
737
+ path = Path(sys.argv[1])
738
+ text, _ = read_regular_document(path)
739
+ records = parse(text)
740
+ emit_records(records)
741
+ except UnicodeError:
742
+ print(UNICODE_ERROR, file=sys.stderr)
743
+ return 2
744
+ except FleetEnvParseError as error:
745
+ print(f"fleet environment parse error: {error}", file=sys.stderr)
746
+ return 2
747
+ except FleetEnvRecoveryError:
748
+ print(
749
+ "fleet environment write error: concurrent replacement preserved "
750
+ "for operator recovery",
751
+ file=sys.stderr,
752
+ )
753
+ return 2
754
+ except OSError:
755
+ print("fleet environment write error: update was not committed", file=sys.stderr)
756
+ return 2
757
+ return 0
758
+
759
+
760
+ if __name__ == "__main__":
761
+ raise SystemExit(main())