@homeflare/alchemy 0.37.7 โ†’ 0.37.8

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 (33) hide show
  1. package/dist/linux/container-form.d.ts +1 -1
  2. package/dist/linux/container-generator.d.ts +28 -0
  3. package/dist/linux/container-generator.d.ts.map +1 -1
  4. package/dist/linux/container-lifecycle.d.ts +18 -1
  5. package/dist/linux/container-lifecycle.d.ts.map +1 -1
  6. package/dist/linux/container-preflight.d.ts +34 -0
  7. package/dist/linux/container-preflight.d.ts.map +1 -1
  8. package/dist/linux/container-settle.d.ts.map +1 -1
  9. package/dist/linux/fake-sudo.d.ts +14 -2
  10. package/dist/linux/fake-sudo.d.ts.map +1 -1
  11. package/dist/linux/index.js +76 -29
  12. package/dist/linux/index.js.map +11 -10
  13. package/dist/linux/sudo-exec.d.ts +16 -0
  14. package/dist/linux/sudo-exec.d.ts.map +1 -1
  15. package/dist/linux/unit-form.d.ts.map +1 -1
  16. package/dist/linux/unit-text.d.ts +35 -0
  17. package/dist/linux/unit-text.d.ts.map +1 -0
  18. package/docs/linux-sudo.md +18 -7
  19. package/package.json +1 -1
  20. package/src/linux/container-form.ts +1 -1
  21. package/src/linux/container-generator.test.ts +33 -0
  22. package/src/linux/container-generator.ts +72 -0
  23. package/src/linux/container-lifecycle.test.ts +6 -3
  24. package/src/linux/container-lifecycle.ts +23 -2
  25. package/src/linux/container-preflight.ts +47 -0
  26. package/src/linux/container-settle.ts +12 -1
  27. package/src/linux/container-shadow.test.ts +131 -0
  28. package/src/linux/fake-sudo.ts +21 -5
  29. package/src/linux/sudo-exec.ts +60 -10
  30. package/src/linux/sudo-lifecycle.test.ts +64 -3
  31. package/src/linux/unit-form.ts +4 -18
  32. package/src/linux/unit-text.test.ts +67 -0
  33. package/src/linux/unit-text.ts +58 -0
@@ -23,13 +23,44 @@
23
23
  * is a valid type in `unit-form.ts`'s `TYPES` set) would also have elevated. Grepped: no code
24
24
  * in this package ever calls `enable`/`start`/`restart` on a unit it has not itself just
25
25
  * written a file for, so narrowing the exemption to `stop`/`disable` costs nothing legitimate.
26
+ * ๐Ÿ”ด MEASURED, CT100 deploy 2026-09-26 00:11Z: a THIRD gap, this one refusing a call that should
27
+ * have been allowed. `Podman.Container caddy`'s generated `caddy.service` has
28
+ * `FragmentPath=/run/systemd/generator/caddy.service` โ€” Quadlet's generator output, never a
29
+ * path this runner would ever declare as a prefix (nothing writes there directly) โ€” so `restart`
30
+ * was refused outright, even though the `.container` FILE THAT GENERATED IT lives at
31
+ * `/etc/containers/systemd/caddy.container`, squarely under this runner's own
32
+ * `/etc/containers/systemd` prefix, and this SAME runner had just written it. `SourcePath` is
33
+ * the generator's own record of that file (systemctl.ts's header, MEASURED on CT100). So a
34
+ * generated unit is now also reachable when its `FragmentPath` is the generator's OWN output
35
+ * directory (`GENERATOR_DIRECTORIES` below) AND its `SourcePath` is under a declared prefix โ€”
36
+ * the thing this runner actually owns is the `.container` file, and the generated unit is
37
+ * systemd's own derivative of it, not a foreign unit. Everything the check refused before still
38
+ * refuses: a vendor unit's `FragmentPath` (`/usr/lib/systemd/system/...`) is not under a
39
+ * generator directory at all; a generated unit with no `SourcePath`, or one outside every
40
+ * prefix (a `.container` file elsewhere in Quadlet's OWN search path โ€”
41
+ * `podman-systemd.unit(5)` โ€” that this runner was never told to own), still refuses.
26
42
  */
27
43
  import type { ExecResult, HostRunner } from '../launchd/runner.ts';
44
+ import { GENERATED_UNIT_DIRECTORY } from './container-generator.ts';
28
45
  import { SYSTEMCTL_ABS, SudoRefusedError, canonicalize, prefixOf } from './sudo-allowlist.ts';
29
46
  import { type ChainExpect, assertGuardedChain } from './sudo-guard.ts';
30
47
  import type { Elevate } from './sudo-write.ts';
31
48
  import { parseShow } from './systemctl.ts';
32
49
 
50
+ /**
51
+ * โ˜… ONLY THE `.late` VARIANT ADDED โ€” `container-generator.ts`'s own header names it as part of
52
+ * systemd's real Unit File Load Path; `.early` is not named anywhere in this repo and no
53
+ * generator this family drives (Quadlet writes only the base directory) uses it, so it stays
54
+ * out rather than widen the check on an unmeasured guess.
55
+ */
56
+ const GENERATOR_DIRECTORIES: readonly string[] = [
57
+ GENERATED_UNIT_DIRECTORY,
58
+ `${GENERATED_UNIT_DIRECTORY}.late`,
59
+ ];
60
+
61
+ const isUnderGeneratorDirectory = (fragmentPath: string): boolean =>
62
+ GENERATOR_DIRECTORIES.some((dir) => fragmentPath.startsWith(`${dir}/`));
63
+
33
64
  const DIR_EXPECT: Readonly<Record<string, ChainExpect>> = {
34
65
  chmod: 'directory',
35
66
  chown: 'directory',
@@ -43,26 +74,33 @@ const EMPTY_FRAGMENT_OK: ReadonlySet<string> = new Set(['stop', 'disable']);
43
74
  const unitStatusOf = async (
44
75
  base: HostRunner,
45
76
  unit: string,
46
- ): Promise<{ readonly fragmentPath: string; readonly loadState: string }> => {
77
+ ): Promise<{
78
+ readonly fragmentPath: string;
79
+ readonly loadState: string;
80
+ readonly sourcePath: string;
81
+ }> => {
47
82
  const result = await base.exec([
48
83
  SYSTEMCTL_ABS,
49
84
  'show',
50
85
  '--no-pager',
51
86
  '-p',
52
- 'FragmentPath,LoadState',
87
+ // โ˜… SourcePath added for the Quadlet-generated-unit exception below โ€” same property
88
+ // systemctl.ts's own `showUnit` already reads for `Podman.Container`.
89
+ 'FragmentPath,LoadState,SourcePath',
53
90
  '--',
54
91
  unit,
55
92
  ]);
56
93
  if (result.exitCode !== 0) {
57
94
  throw new SudoRefusedError(
58
- `sshSudoRunner ${unit}: could not read FragmentPath/LoadState (systemctl show exit ` +
59
- `${String(result.exitCode)}). Nothing ran as root.`,
95
+ `sshSudoRunner ${unit}: could not read FragmentPath/LoadState/SourcePath (systemctl show ` +
96
+ `exit ${String(result.exitCode)}). Nothing ran as root.`,
60
97
  );
61
98
  }
62
99
  const fields = parseShow(result.stdout);
63
100
  return {
64
101
  fragmentPath: fields.get('FragmentPath') ?? '',
65
102
  loadState: fields.get('LoadState') ?? '',
103
+ sourcePath: fields.get('SourcePath') ?? '',
66
104
  };
67
105
  };
68
106
 
@@ -93,7 +131,7 @@ export const elevatedExec = (
93
131
  const verb = canonical[1] ?? '';
94
132
  const unit = canonical[3] ?? '';
95
133
  return elevate(canonical, {}, async () => {
96
- const { fragmentPath, loadState } = await unitStatusOf(base, unit);
134
+ const { fragmentPath, loadState, sourcePath } = await unitStatusOf(base, unit);
97
135
  if (loadState === 'masked') {
98
136
  throw new SudoRefusedError(
99
137
  `sshSudoRunner ${unit}: is masked. Masking is someone's decision, not drift; unmask it ` +
@@ -108,11 +146,23 @@ export const elevatedExec = (
108
146
  `${unit} is (a kernel-generated unit, say). Nothing ran as root.`,
109
147
  );
110
148
  }
111
- if (prefixOf(fragmentPath, prefixes) === undefined) {
112
- throw new SudoRefusedError(
113
- `sshSudoRunner ${unit}: FragmentPath ${fragmentPath} is outside every declared prefix ` +
114
- `(${prefixes.join(', ')}); refusing to touch a unit this runner does not own. Nothing ran as root.`,
115
- );
149
+ if (prefixOf(fragmentPath, prefixes) !== undefined) return;
150
+ // โ›” THE QUADLET CASE โ€” see this file's header. A generator's OWN output directory is never a
151
+ // prefix, so this is not "found it under a prefix, done" โ€” it is "found it under the
152
+ // generator, so check what GENERATED it instead."
153
+ if (
154
+ isUnderGeneratorDirectory(fragmentPath) &&
155
+ sourcePath !== '' &&
156
+ prefixOf(sourcePath, prefixes) !== undefined
157
+ ) {
158
+ return;
116
159
  }
160
+ const sourceNote =
161
+ sourcePath === '' ? '' : `, and its SourcePath ${sourcePath} is not under one either`;
162
+ throw new SudoRefusedError(
163
+ `sshSudoRunner ${unit}: FragmentPath ${fragmentPath} is outside every declared prefix ` +
164
+ `(${prefixes.join(', ')})${sourceNote}; refusing to touch a unit this runner does not ` +
165
+ 'own. Nothing ran as root.',
166
+ );
117
167
  });
118
168
  };
@@ -1,15 +1,19 @@
1
1
  /**
2
- * `Systemd.Unit` and `Remote.File`'s own lifecycle functions, driven through sshSudoRunner over
3
- * the fake Linux host โ€” proving the two families this runner exists for actually work end to end,
4
- * not just that the runner's own primitives do. No sudo runs anywhere but through the fake.
2
+ * `Systemd.Unit`, `Remote.File` and `Podman.Container`'s own lifecycle functions, driven through
3
+ * sshSudoRunner over the fake Linux host โ€” proving the families this runner exists for actually
4
+ * work end to end, not just that the runner's own primitives do. No sudo runs anywhere but through
5
+ * the fake.
5
6
  */
6
7
  import { describe, expect, test } from 'bun:test';
8
+ import { fakeQuadletHost } from './fake-quadlet.ts';
7
9
  import { fakeSudoHost } from './fake-sudo.ts';
8
10
  import { SudoRefusedError } from './sudo-runner.ts';
9
11
  import { deleteFile, reconcileFile } from './remote-file-lifecycle.ts';
10
12
  import type { RemoteFileProps } from './remote-file-form.ts';
11
13
  import { deleteUnit, diffUnit, reconcileUnit } from './unit-lifecycle.ts';
12
14
  import type { SystemdUnitProps } from './unit-form.ts';
15
+ import { reconcileContainer } from './container-lifecycle.ts';
16
+ import type { ContainerProps } from './container-form.ts';
13
17
 
14
18
  const UNIT: SystemdUnitProps = {
15
19
  name: 'hf-thing.service',
@@ -124,6 +128,63 @@ describe('Systemd.Unit through sshSudoRunner', () => {
124
128
  });
125
129
  });
126
130
 
131
+ // ๐Ÿ”ด REGRESSION, CT100 deploy 2026-09-26 00:11Z: `Podman.Container caddy`'s generated unit's
132
+ // FragmentPath (`/run/systemd/generator/โ€ฆ`) was refused outright by the ownership check above,
133
+ // even though its SourcePath (`/etc/containers/systemd/caddy.container`) was this runner's own
134
+ // `.container` file, under a declared prefix. `fakeQuadletHost` in place of the plain
135
+ // `fakeLinuxHost` (via `fakeSudoHost`'s `hostFactory`) reproduces the exact CT100 shape: a
136
+ // generated unit whose FragmentPath is the generator's own output directory.
137
+ const CADDY_PREFIXES = ['/etc/containers/systemd', '/etc/systemd/system'];
138
+ const CADDY: ContainerProps = {
139
+ container: { image: 'docker.io/library/caddy:2', network: 'host' },
140
+ install: { wantedBy: ['multi-user.target'] },
141
+ name: 'caddy',
142
+ service: { restart: 'always' },
143
+ };
144
+
145
+ describe('Podman.Container through sshSudoRunner', () => {
146
+ test(
147
+ 'a full reconcile writes, reloads and starts โ€” the generated unitโ€™s FragmentPath under ' +
148
+ 'the generator is reachable because its SourcePath is under a declared prefix',
149
+ async () => {
150
+ const { privileged, runner } = fakeSudoHost({}, undefined, {
151
+ hostFactory: fakeQuadletHost,
152
+ prefixes: CADDY_PREFIXES,
153
+ });
154
+ const attrs = await reconcileContainer(runner, CADDY, undefined);
155
+ expect(attrs.active).toBe(true);
156
+ expect(attrs.sourcePath).toBe('/etc/containers/systemd/caddy.container');
157
+ const verbs = privileged()
158
+ .filter((c) => c[0] === '/usr/bin/systemctl')
159
+ .map((c) => c[1]);
160
+ expect(verbs).toContain('daemon-reload');
161
+ expect(verbs).toContain('start');
162
+ },
163
+ );
164
+
165
+ test('a generated unit whose SourcePath is outside every declared prefix still refuses', async () => {
166
+ const { fake, privileged, runner } = fakeSudoHost({}, undefined, {
167
+ hostFactory: fakeQuadletHost,
168
+ prefixes: CADDY_PREFIXES,
169
+ });
170
+ // A `.container` file Quadlet's OWN search path would also read (podman-systemd.unit(5)), but
171
+ // not under any prefix THIS runner declared โ€” a foreign Quadlet source, same idea as the
172
+ // vendor `pveproxy.service` test above for a plain unit.
173
+ fake.files.set('/run/containers/systemd/other.container', {
174
+ bytes: new TextEncoder().encode('[Container]\nImage=example/other:1\n'),
175
+ gid: 0,
176
+ kind: 'file',
177
+ mode: 0o644,
178
+ uid: 0,
179
+ });
180
+ await runner.exec(['systemctl', 'daemon-reload']);
181
+ await expect(runner.exec(['systemctl', 'restart', '--', 'other.service'])).rejects.toThrow(
182
+ /SourcePath .* is not under one either/,
183
+ );
184
+ expect(privileged().some((c) => c[1] === 'restart')).toBe(false);
185
+ });
186
+ });
187
+
127
188
  const SCRIPT: RemoteFileProps = {
128
189
  content: '#!/bin/sh\necho hi\n',
129
190
  mode: 0o755,
@@ -12,8 +12,11 @@
12
12
  * โ›” `content` IS NEVER A SECRET. Props sit unencrypted in Alchemy state and the unit file is
13
13
  * world-readable at 0644; a unit that needs a secret takes `EnvironmentFile=` pointing at a file
14
14
  * a secret renderer maintains.
15
+ * โ˜… THE TEXT-SHAPE CHECKS (comments/blank lines before [Section], [Install] key lookup) live in
16
+ * `unit-text.ts`, split out to keep this file under the house line cap.
15
17
  */
16
18
  import { sha256Hex } from '../launchd/job-form.ts';
19
+ import { hasInstallSection, textProblems } from './unit-text.ts';
17
20
 
18
21
  export type UnitSection = {
19
22
  /** `Unit`, `Service`, `Timer`, `Install`, โ€ฆ โ€” verbatim, including its capitalisation. */
@@ -172,8 +175,6 @@ const TYPES = new Set([
172
175
  'timer',
173
176
  ]);
174
177
 
175
- const INSTALL_KEYS = new Set(['WantedBy', 'RequiredBy', 'UpsertedBy', 'Also', 'Alias']);
176
-
177
178
  /**
178
179
  * Every refusal at once, so one plan shows the whole list.
179
180
  * โ›” THE TWO STRUCTURAL RULES, both proven by systemd's own output rather than assumed:
@@ -206,7 +207,7 @@ export const unitProblems = (props: SystemdUnitProps, expect?: string): string[]
206
207
  `${directory} is not one of them. Move it, or declare enabled: false.`,
207
208
  );
208
209
  }
209
- if (props.enabled !== false && !hasInstall(props)) {
210
+ if (props.enabled !== false && !hasInstallSection(unitText(props))) {
210
211
  found.push(
211
212
  'enabled units need an [Install] section with WantedBy/RequiredBy/Also/Alias; without one ' +
212
213
  'systemd calls the unit "static" and `systemctl enable` has nothing to link. Add one, or ' +
@@ -218,18 +219,3 @@ export const unitProblems = (props: SystemdUnitProps, expect?: string): string[]
218
219
  }
219
220
  return found;
220
221
  };
221
-
222
- const hasInstall = (props: SystemdUnitProps): boolean => {
223
- const text = unitText(props);
224
- if (!text.includes('[Install]')) return false;
225
- const after = text.slice(text.indexOf('[Install]'));
226
- return [...INSTALL_KEYS].some((key) => new RegExp(`^${key}=`, 'm').test(after));
227
- };
228
-
229
- const textProblems = (text: string): string[] => {
230
- const found: string[] = [];
231
- if (text.trim() === '') found.push('the unit file is empty');
232
- if (text.includes('\u0000')) found.push('the unit file contains NUL');
233
- if (!/^\s*\[[A-Za-z]+]/.test(text)) found.push('a unit file must begin with a [Section]');
234
- return found;
235
- };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `unit-text.ts`'s own tests โ€” the INI text-shape checks, on their own, separate from
3
+ * `unit-lifecycle.test.ts`'s end-to-end fake-host behaviour.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { hasInstallSection, textProblems } from './unit-text.ts';
7
+
8
+ // ๐Ÿ”ด REGRESSION, CT100 deploy 2026-09-26 00:11Z: `assertValid` refused this EXACT content โ€” "a
9
+ // unit file must begin with a [Section]" โ€” even though it is byte-identical to what systemd
10
+ // already loads on CT100 (sha256 verified against the live host). Leading lines copied
11
+ // verbatim from homeflare-ct100's src/openbao-agent/files/openbao-agent.service.
12
+ const CT100_OPENBAO_AGENT = [
13
+ "# CT100's openbao-agent.",
14
+ '#',
15
+ "# THIS FILE WAS RECOVERED FROM THE RUNNING BOX, NOT PUSHED TO IT. The repo's copy was STALE",
16
+ '# and would have REMOVED `Environment=BAO_NAMESPACE=homeflare` -- without which the agent',
17
+ '# authenticates into the root namespace, every template 403s, and the failure reads as a',
18
+ '# missing grant rather than a missing variable. That is the exact shape the vault policies',
19
+ '# README warns about: a deploy converging a stale copy over a working live one.',
20
+ '# So the FIRST run of deploy-ct100.sh was a --dry-run, and this is what it was for.',
21
+ '[Unit]',
22
+ 'Description=OpenBao agent',
23
+ 'After=network-online.target',
24
+ 'Wants=network-online.target',
25
+ '[Service]',
26
+ 'Environment=BAO_NAMESPACE=homeflare',
27
+ 'ExecStart=/usr/bin/bao agent -config=/etc/openbao/agent.hcl',
28
+ 'RuntimeDirectory=openbao',
29
+ 'Restart=on-failure',
30
+ 'RestartSec=10',
31
+ '[Install]',
32
+ 'WantedBy=multi-user.target',
33
+ '',
34
+ ].join('\n');
35
+
36
+ describe('textProblems', () => {
37
+ test('the CT100 openbao-agent.service fixture validates โ€” comments may precede [Unit]', () => {
38
+ expect(textProblems(CT100_OPENBAO_AGENT)).toEqual([]);
39
+ });
40
+
41
+ test('blank lines alone may also precede the first section', () => {
42
+ expect(textProblems('\n\n[Unit]\nDescription=x\n')).toEqual([]);
43
+ });
44
+
45
+ test('a ; comment is accepted the same as a # comment', () => {
46
+ expect(textProblems('; a semicolon comment\n[Unit]\nDescription=x\n')).toEqual([]);
47
+ });
48
+
49
+ test('a genuine key=value before any section still refuses โ€” comments are not a blanket exemption', () => {
50
+ expect(textProblems('Description=no section at all\n[Unit]\n').join(' ')).toMatch(
51
+ /must begin with a \[Section\]/,
52
+ );
53
+ });
54
+
55
+ test('an empty file and one with NUL are still refused', () => {
56
+ expect(textProblems(' \n').join(' ')).toMatch(/is empty/);
57
+ expect(textProblems('[Unit]\nDescription=a\u0000b\n').join(' ')).toMatch(/contains NUL/);
58
+ });
59
+ });
60
+
61
+ describe('hasInstallSection', () => {
62
+ test('true only once an [Install] key actually appears after the header', () => {
63
+ expect(hasInstallSection('[Unit]\n[Install]\nWantedBy=multi-user.target\n')).toBe(true);
64
+ expect(hasInstallSection('[Unit]\nDescription=x\n')).toBe(false);
65
+ expect(hasInstallSection('[Unit]\n[Install]\n')).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The parts of `unit-form.ts`'s validation that inspect the unit file's TEXT rather than its
3
+ * props struct โ€” split out so unit-form.ts stays under the file cap, and because these are one
4
+ * coherent concern: is this INI shape something systemd would actually load. Takes plain strings,
5
+ * never `SystemdUnitProps`, so it never needs to import back from unit-form.ts.
6
+ *
7
+ * โ›” `[Install]` KEYS ARE `unit-form.ts`'s OWN LIST, NOT SYSTEMD'S FULL VOCABULARY โ€” WantedBy,
8
+ * RequiredBy, UpsertedBy, Also, Alias, per `systemd.unit(5)` ยง[Install]. `container-form.ts`'s
9
+ * own header notes it deliberately does NOT reuse this set: a `.container` file's `[Install]`
10
+ * supports only Alias/WantedBy/RequiredBy (`podman-systemd.unit(5)`), narrower than this.
11
+ */
12
+
13
+ export const INSTALL_KEYS = new Set(['WantedBy', 'RequiredBy', 'UpsertedBy', 'Also', 'Alias']);
14
+
15
+ /** Whether `text` has an `[Install]` section carrying at least one key systemd actually links on. */
16
+ export const hasInstallSection = (text: string): boolean => {
17
+ if (!text.includes('[Install]')) return false;
18
+ const after = text.slice(text.indexOf('[Install]'));
19
+ return [...INSTALL_KEYS].some((key) => new RegExp(`^${key}=`, 'm').test(after));
20
+ };
21
+
22
+ /**
23
+ * The first line that is neither blank nor a comment โ€” the line whose shape actually decides
24
+ * whether the file "begins with a [Section]", per `textProblems` below.
25
+ * โ˜… systemd.syntax(7): "Empty lines and lines starting with '#' or ';' are ignored", with no
26
+ * carve-out for the position before the first section โ€” a comment header is ordinary content
27
+ * there, same as anywhere else in the file.
28
+ */
29
+ export const firstSubstantiveLine = (text: string): string | undefined =>
30
+ text
31
+ .split('\n')
32
+ .find((line) => !(line.trim() === '' || line.startsWith('#') || line.startsWith(';')));
33
+
34
+ /**
35
+ * ๐Ÿ”ด MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: the openbao-agent.service asset's first eight
36
+ * lines are `#`-comments recording where the file was recovered from (verbatim from the live
37
+ * host โ€” homeflare-ct100's `src/openbao-agent/files/openbao-agent.service`), and the file is
38
+ * byte-identical to what systemd already loads there. The old check tested the WHOLE text
39
+ * against `/^\s*\[[A-Za-z]+]/` โ€” `\s*` skips blank lines and indentation, never a `#`/`;`
40
+ * comment โ€” so a leading comment line failed it outright. This checks only the first
41
+ * SUBSTANTIVE line, so any number of leading blank lines and comments are fine and a genuine
42
+ * `key=value` before any section โ€” the one real error this exists to catch โ€” still fails, since
43
+ * that line is neither blank nor a comment either.
44
+ */
45
+ export const textProblems = (text: string): string[] => {
46
+ const found: string[] = [];
47
+ if (text.trim() === '') found.push('the unit file is empty');
48
+ if (text.includes('\u0000')) found.push('the unit file contains NUL');
49
+ const first = firstSubstantiveLine(text);
50
+ // โ˜… `\s*` kept from the original regex โ€” leading indentation on the SECTION line itself was
51
+ // already tolerated before this fix and stays that way; only the leading-comment shape changes.
52
+ if (first === undefined || !/^\s*\[[A-Za-z]+]/.test(first)) {
53
+ found.push(
54
+ 'a unit file must begin with a [Section] (blank lines and #/; comments before it are fine)',
55
+ );
56
+ }
57
+ return found;
58
+ };