Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
- name: "Checkout"
uses: actions/checkout@v4

- name: "Setup NodeJS 22"
- name: "Setup NodeJS 24"
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'
cache: 'yarn'

- name: Install
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ jobs:
with:
fetch-depth: 0

- name: "Setup NodeJS 22"
- name: "Setup NodeJS 24"
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '24'
registry-url: 'https://registry.npmjs.org'
cache: 'yarn'

Expand Down
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.14.0
62 changes: 62 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

Yarn 4 + Lerna-lite monorepo. All scripts run from the repo root unless noted.

- `yarn build` — `tsc --build` across the workspace via root project references in `tsconfig.json`. Builds emit to each package's `lib/`.
- `yarn build:watch` — incremental build in watch mode.
- `yarn test` — runs Vitest once (`vitest --run`) across both packages via `vitest.workspace.ts`.
- `yarn test:watch` — Vitest in watch mode.
- `yarn test:coverage` — Vitest with coverage.
- `yarn lint` / `yarn lint:fix` — ESLint with cache (`.eslintcache` is committed-ignored). Type-aware rules require a successful `yarn build` of the package's tsconfig because the ESLint config points `parserOptions.project` at each `packages/*/tsconfig.json`.
- Run a single package's scripts: `yarn workspace @systemd-js/conf test` (or `lint`, `build`, etc.). Same scripts exist on each package.
- Run a single test file or test name: `yarn workspace @systemd-js/conf test src/__tests__/service.test.ts` or filter with `-t "<name pattern>"`.

Node `^24` is the declared engine; CI uses Node 24. Husky `pre-commit` runs `yarn test` (not lint-staged — `lint-staged` is configured but not wired into a hook); `commit-msg` runs commitlint with `@commitlint/config-conventional` and a **mandatory non-empty scope** (e.g. `feat(conf): ...`, `fix(ctl): ...`).

## Releases

`master` is the only release branch. Releases are fully automated by `.github/workflows/publish.yaml` on PR merge: it runs `lerna version --conventional-commits` then `lerna publish from-git`. Both packages are versioned in lockstep (`lerna.json` `version`). Don't bump versions or edit `CHANGELOG.md` by hand — conventional-commit messages drive both.

## Architecture

Two packages with a strict dependency direction: **`conf` is pure** (parse/build), **`ctl` wraps the OS** (`systemctl` + filesystem) and depends on `conf`.

### `@systemd-js/conf` — unit file model

The shape every unit follows (`Service`, `Timer`, `Container`):

1. A TypeScript `interface` for each section (`UnitSection`, `ServiceSection`, `TimerSection`, `InstallSectionConfig`, `ExecSectionConfig`, `KillSectionConfig`, `ResourceSectionConfig`, `ContainerSection`) that mirrors the systemd man-page directives 1:1, with the man-page text preserved as JSDoc.
2. A Zod schema declared via the `implement<Interface>().with({ ... })` helper in `utils.ts`. This helper *requires* the schema to cover every key of the interface (and only those keys) — it's how interface and runtime validator stay in sync. When you add a directive, add it to the interface, the schema, **and** the builder.
3. A `*SectionBuilder` class with chainable `setX()` setters and a `toObject()` that re-validates through the schema.
4. A top-level `Unit` class (`Service`/`Timer`/`Container`) that composes the section builders, exposes `getXSection()` accessors, and implements `AbstractUnit` (`toObject`, `toINIString`, `equals`) plus statics `getType()`, `fromObject(obj)`, `fromINI(ini)`.

Section builders share behavior via **runtime mixins**, not inheritance: `ServiceSectionBuilder` declares `extends ExecSectionBuilder, KillSectionBuilder, ResourceSectionBuilder` as a TypeScript `interface` (declaration-merging on the class), and `applyMixins(...)` from `utils.ts` copies the prototypes at module load. `TimerSectionBuilder` does the same with `ExecSectionBuilder`. The `@typescript-eslint/no-unsafe-declaration-merging` rule is disabled in `eslint.config.js` specifically to allow this.

`INI` (`ini.ts`) is the only (de)serializer. It is **not** a full systemd parser: no quoting, no escaping, no continuation-line semantics beyond a basic `\\\n` strip. Booleans round-trip as `yes`/`no`; multiple assignments to the same key collapse into a string array (e.g. multiple `ExecStartPre=`); `1`/`0` warn and are coerced to booleans.

`Container` units are quadrux-wrapped: they have `[Unit]`, `[Container]`, optional `[Install]`, **and** optional `[Service]` (Quadlet/podman-systemd convention).

All unit equality (`equals`) is `JSON.stringify(toObject())` — order-sensitive. Don't reorder builder keys casually.

### `@systemd-js/ctl` — runtime control

`ctl.ts` is the entire surface. It maps unit names to filesystem paths:
- `*.service`, `*.timer` → `/etc/systemd/system/<name>.<type>`
- `*.container` → `/etc/containers/systemd/<name>.<type>` (Quadlet location)

Type detection: pass an explicit `Unit` instance (`(unit.constructor as typeof Service).getType()`) or it's parsed from the filename extension. `enable`/`disable` reject containers (Quadlet doesn't use systemctl enable). `write()` reads the existing on-disk unit (if any), compares via `unit.equals(current)`, and returns `"created" | "updated" | "unchanged"` — only writing on diff.

Every other operation shells out via `execSync("systemctl ...")` — no daemon-reload is implicit, callers must invoke `daemonReload()` themselves after `write()` if they want systemd to pick up changes. There is no error wrapping; failures throw the raw `execSync` error. The README explicitly notes "lack proper error handling" — keep that in mind before adding swallowing try/catches.

The `Ctl` class is a thin stateful wrapper around the same functions; it caches `current` (on-disk unit) at construction time, so a second `new Ctl(name)` is needed to see external changes.

## Conventions

- ESM only (`"type": "module"`). Relative imports must use the **`.js` extension** even from `.ts` source (e.g. `import { INI } from "./ini.js"`) — required by Node ESM resolution after `tsc` emits `.js`.
- The `@chyzwar/eslint-config/node` config and `@chyzwar/tsconfig/lib.json` are external; behavior is dictated there, not in-repo. Don't fight the formatter — run `yarn lint:fix`.
- Keep the man-page JSDoc verbatim when adding new directives (`exec.ts`, `service.ts` etc. follow this pattern). The pinned reference is systemd v255.4.
- Tests live in `packages/*/src/__tests__/*.test.ts` and import from sibling files using the `.js` extension.
26 changes: 13 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@systemd/root",
"version": "1.0.0",
"engines": {
"node": "^20.0.0"
"node": "^24.0.0"
},
"type": "module",
"workspaces": [
Expand All @@ -22,20 +22,20 @@
"prepare": "husky"
},
"devDependencies": {
"@chyzwar/eslint-config": "^0.2.34",
"@commitlint/cli": "^19.7.1",
"@commitlint/config-conventional": "^19.7.1",
"@lerna-lite/cli": "^3.12.0",
"@lerna-lite/publish": "^3.12.0",
"@lerna-lite/version": "^3.12.0",
"@types/node": "^22.13.4",
"conventional-changelog-conventionalcommits": "^7.0.2",
"eslint": "^9.20.1",
"@chyzwar/eslint-config": "^0.6.4",
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@lerna-lite/cli": "^5.2.1",
"@lerna-lite/publish": "^5.2.1",
"@lerna-lite/version": "^5.2.1",
"@types/node": "^25.7.0",
"conventional-changelog-conventionalcommits": "^9.3.1",
"eslint": "^10.3.0",
"husky": "^9.1.7",
"lint-staged": "^15.4.3",
"lint-staged": "^17.0.4",
"ts-node": "^10.9.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"lint-staged": {
"*.{js,ts,tsx}": "yarn lint:fix"
Expand Down
16 changes: 8 additions & 8 deletions packages/conf/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@systemd-js/conf",
"version": "0.10.0",
"engines": {
"node": "^20.0.0"
"node": "^24.0.0"
},
"repository": {
"type": "git",
Expand Down Expand Up @@ -33,14 +33,14 @@
"test:watch": "vitest"
},
"dependencies": {
"zod": "^3.24.2"
"zod": "^4.4.3"
},
"devDependencies": {
"@chyzwar/eslint-config": "^0.2.34",
"@chyzwar/tsconfig": "^0.3.2",
"@types/node": "^22.13.4",
"eslint": "^9.20.1",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
"@chyzwar/eslint-config": "^0.6.4",
"@chyzwar/tsconfig": "^0.4.0",
"@types/node": "^25.7.0",
"eslint": "^10.3.0",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
}
}
156 changes: 156 additions & 0 deletions packages/conf/src/__tests__/resource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import { INI } from "../ini.js";
import { Service } from "../service.js";

const baseObj = {
Unit: { Description: "rsrc test" },
Service: {
Type: "simple" as const,
ExecStart: "/bin/true",
},
};

describe("Resource section", () => {
describe("round-trip", () => {
it("preserves multi-assignment directives through INI string", () => {
const obj = {
...baseObj,
Service: {
...baseObj.Service,
IODeviceWeight: ["/dev/sda 200", "/dev/sdb 100"],
IOReadBandwidthMax: "/dev/sda 5M",
IPAddressAllow: ["10.0.0.0/8", "::1"],
IPAddressDeny: "any",
SocketBindAllow: ["ipv6:tcp", "ipv4:udp:10000-65535"],
RestrictNetworkInterfaces: ["eth0", "eth1"],
DeviceAllow: ["/dev/null rw", "char-pts rw"],
BPFProgram: "egress:/sys/fs/bpf/egress-hook",
},
};

const svc = new Service(obj);
const round = Service.fromINI(INI.fromString(svc.toINIString()));
expect(round.toObject()).toMatchObject(obj);
});

it("preserves single-value enum and boolean directives", () => {
const obj = {
...baseObj,
Service: {
...baseObj.Service,
DevicePolicy: "strict" as const,
Delegate: "cpu io",
DelegateSubgroup: "supervisor",
ManagedOOMSwap: "kill" as const,
ManagedOOMMemoryPressure: "kill" as const,
ManagedOOMMemoryPressureLimit: "60%",
ManagedOOMMemoryPressureDurationSec: "5s",
ManagedOOMPreference: "avoid" as const,
MemoryPressureWatch: "auto" as const,
MemoryPressureThresholdSec: "150ms",
MemoryZSwapWriteback: false,
CoredumpReceive: true,
IPAccounting: true,
Slice: "system.slice",
},
};

const svc = new Service(obj);
const round = Service.fromINI(INI.fromString(svc.toINIString()));
expect(round.toObject()).toMatchObject(obj);
});

it("accepts ResourceLimit suffix variants", () => {
const obj = {
...baseObj,
Service: {
...baseObj.Service,
MemoryMax: "512M" as const,
MemoryHigh: "1G" as const,
MemoryLow: "10%" as const,
MemoryMin: "infinity" as const,
MemorySwapMax: "256M" as const,
TasksMax: "50%" as const,
},
};

const svc = new Service(obj);
const round = Service.fromINI(INI.fromString(svc.toINIString()));
expect(round.toObject()).toMatchObject(obj);
});

it("accepts Delegate as boolean", () => {
const obj = {
...baseObj,
Service: { ...baseObj.Service, Delegate: true },
};

const svc = new Service(obj);
const round = Service.fromINI(INI.fromString(svc.toINIString()));
expect(round.toObject()).toMatchObject(obj);
});
});

describe("validation", () => {
it("rejects garbage TasksMax", () => {
expect(() => new Service({
...baseObj,
Service: { ...baseObj.Service, TasksMax: "garbage" as never },
})).toThrow();
});

it("rejects invalid ResourceLimit suffix", () => {
expect(() => new Service({
...baseObj,
Service: { ...baseObj.Service, MemoryMax: "10Q" as never },
})).toThrow();
});

it("rejects unknown DevicePolicy", () => {
expect(() => new Service({
...baseObj,
Service: { ...baseObj.Service, DevicePolicy: "open" as never },
})).toThrow();
});

it("rejects unknown ManagedOOMPreference", () => {
expect(() => new Service({
...baseObj,
Service: { ...baseObj.Service, ManagedOOMPreference: "preferred" as never },
})).toThrow();
});
});

describe("builder", () => {
it("sets new resource directives via setters", () => {
const svc = new Service();
svc.getUnitSection().setDescription("rsrc builder");
svc.getServiceSection()
.setType("simple")
.setExecStart("/bin/true")
.setIODeviceWeight(["/dev/sda 200", "/dev/sdb 100"])
.setIPAddressAllow(["10.0.0.0/8"])
.setDevicePolicy("strict")
.setDelegate("cpu io")
.setManagedOOMSwap("kill")
.setMemoryZSwapWriteback(false)
.setCoredumpReceive(true)
.setTasksMax("50%");

expect(svc.toObject()).toMatchObject({
Service: {
Type: "simple",
ExecStart: "/bin/true",
IODeviceWeight: ["/dev/sda 200", "/dev/sdb 100"],
IPAddressAllow: ["10.0.0.0/8"],
DevicePolicy: "strict",
Delegate: "cpu io",
ManagedOOMSwap: "kill",
MemoryZSwapWriteback: false,
CoredumpReceive: true,
TasksMax: "50%",
},
});
});
});
});
Loading
Loading