@homeflare/alchemy 0.37.7 → 0.38.0

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 (51) hide show
  1. package/dist/caddy/admin.d.ts +42 -8
  2. package/dist/caddy/admin.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts +9 -3
  4. package/dist/caddy/config.d.ts.map +1 -1
  5. package/dist/caddy/fake-caddy.d.ts +5 -0
  6. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  7. package/dist/caddy/index.js +10 -6
  8. package/dist/caddy/index.js.map +5 -5
  9. package/dist/caddy/providers.d.ts +19 -12
  10. package/dist/caddy/providers.d.ts.map +1 -1
  11. package/dist/linux/container-form.d.ts +1 -1
  12. package/dist/linux/container-generator.d.ts +28 -0
  13. package/dist/linux/container-generator.d.ts.map +1 -1
  14. package/dist/linux/container-lifecycle.d.ts +18 -1
  15. package/dist/linux/container-lifecycle.d.ts.map +1 -1
  16. package/dist/linux/container-preflight.d.ts +34 -0
  17. package/dist/linux/container-preflight.d.ts.map +1 -1
  18. package/dist/linux/container-settle.d.ts.map +1 -1
  19. package/dist/linux/fake-sudo.d.ts +14 -2
  20. package/dist/linux/fake-sudo.d.ts.map +1 -1
  21. package/dist/linux/index.js +76 -29
  22. package/dist/linux/index.js.map +11 -10
  23. package/dist/linux/sudo-exec.d.ts +16 -0
  24. package/dist/linux/sudo-exec.d.ts.map +1 -1
  25. package/dist/linux/unit-form.d.ts.map +1 -1
  26. package/dist/linux/unit-text.d.ts +35 -0
  27. package/dist/linux/unit-text.d.ts.map +1 -0
  28. package/docs/linux-sudo.md +18 -7
  29. package/package.json +1 -1
  30. package/src/caddy/admin-transport-scope.test.ts +127 -0
  31. package/src/caddy/admin.ts +45 -9
  32. package/src/caddy/config-adopt-provider.test.ts +2 -2
  33. package/src/caddy/config-format-warning.test.ts +9 -3
  34. package/src/caddy/config.ts +20 -4
  35. package/src/caddy/fake-caddy.ts +10 -1
  36. package/src/caddy/providers.test.ts +3 -2
  37. package/src/caddy/providers.ts +18 -11
  38. package/src/linux/container-form.ts +1 -1
  39. package/src/linux/container-generator.test.ts +33 -0
  40. package/src/linux/container-generator.ts +72 -0
  41. package/src/linux/container-lifecycle.test.ts +6 -3
  42. package/src/linux/container-lifecycle.ts +23 -2
  43. package/src/linux/container-preflight.ts +47 -0
  44. package/src/linux/container-settle.ts +12 -1
  45. package/src/linux/container-shadow.test.ts +131 -0
  46. package/src/linux/fake-sudo.ts +21 -5
  47. package/src/linux/sudo-exec.ts +60 -10
  48. package/src/linux/sudo-lifecycle.test.ts +64 -3
  49. package/src/linux/unit-form.ts +4 -18
  50. package/src/linux/unit-text.test.ts +67 -0
  51. package/src/linux/unit-text.ts +58 -0
@@ -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
+ };