@armstrongnate/april 0.0.1 → 0.0.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/README.md CHANGED
@@ -8,25 +8,106 @@ april watches for GitHub issues assigned to you with a specific label, then spin
8
8
 
9
9
  - [Node.js](https://nodejs.org/) >= 22
10
10
  - [gh](https://cli.github.com/) (authenticated)
11
+ - The `gh-webhook` extension: `gh extension install cli/gh-webhook`
11
12
  - [tmux](https://github.com/tmux/tmux)
12
13
  - [Claude Code](https://claude.ai/claude-code) CLI
13
14
 
14
- ## Install
15
+ ## Quick install
15
16
 
16
17
  ```bash
17
18
  npm i -g @armstrongnate/april
18
- # or: pnpm add -g @armstrongnate/april
19
+ april init # writes ~/.config/april/config.yaml + skill + env file; checks prereqs
20
+ $EDITOR ~/.config/april/config.yaml
21
+ april install # registers the user service and starts it
22
+ april logs -f
23
+ ```
24
+
25
+ `april init` and the daemon both verify that the `cli/gh-webhook` extension is installed and refuse to proceed without it.
26
+
27
+ ## Server install (full playbook)
28
+
29
+ The minimal flow above leaves out auth setup and a few server-specific gotchas. This is the end-to-end recipe for a fresh Linux server.
30
+
31
+ ### 1. System prerequisites
32
+
33
+ ```bash
34
+ # Install node 22+, tmux, gh, claude code via your usual route, then:
35
+ gh extension install cli/gh-webhook
19
36
  ```
20
37
 
21
- Then:
38
+ ### 2. Create a Personal Access Token
39
+
40
+ The systemd user service runs in a stripped-down environment — no shell config, no keyring/DBus, no credential helpers. Whatever clever auth you have set up in your interactive shell almost certainly will not be available to the daemon. **You need an explicit token in the env file.**
41
+
42
+ Generate one on your GitHub host's web UI: Settings → Developer settings → Personal access tokens.
43
+
44
+ **Classic PAT scopes:**
45
+ - `repo` — issue read/write, label updates, PR creation, code access (no smaller scope works for private repo issues)
46
+ - `admin:repo_hook` — needed for `gh webhook forward` to register and clean up its temporary `cli` webhook
47
+ - `workflow` — only if Claude might modify `.github/workflows/*` files
48
+
49
+ **Fine-grained PAT** (GHES 3.10+):
50
+ - Repository access: select the repos
51
+ - Permissions: Issues R/W, Pull requests R/W, Contents R/W, Webhooks R/W, Metadata R, Workflows R/W (last one optional)
52
+
53
+ The daemon's own needs are smaller (Issues R/W + Webhooks R/W + Metadata R), but Claude inherits the same env when it runs inside tmux, so the token has to cover both — see [Token inheritance](#token-inheritance) below.
54
+
55
+ ### 3. Install the package
22
56
 
23
57
  ```bash
24
- april init # writes ~/.config/april/config.yaml + installs the issue-worker skill
58
+ npm i -g @armstrongnate/april
59
+ april init
25
60
  $EDITOR ~/.config/april/config.yaml
26
- april install # registers the user service and starts it
61
+ ```
62
+
63
+ ### 4. Configure auth in the env file
64
+
65
+ ```bash
66
+ $EDITOR ~/.config/april/env
67
+ ```
68
+
69
+ For a **GitHub Enterprise Server** host:
70
+
71
+ ```
72
+ GH_HOST=your.ghes.host
73
+ GH_ENTERPRISE_TOKEN=ghp_...
74
+ ```
75
+
76
+ For **github.com**:
77
+
78
+ ```
79
+ GH_TOKEN=ghp_...
80
+ ```
81
+
82
+ `gh` is host-aware about which env var it reads: `GH_TOKEN` is github.com-only; `GH_ENTERPRISE_TOKEN` covers any other host (used together with `GH_HOST`). Setting `GH_TOKEN` while `GH_HOST` points elsewhere will silently fail.
83
+
84
+ ### 5. Install and start the service
85
+
86
+ ```bash
87
+ april install
88
+ ```
89
+
90
+ This writes `~/.config/systemd/user/april.service`, enables it, and starts it. The unit references the env file via `EnvironmentFile=-~/.config/april/env`, so anything you put there flows through on each restart.
91
+
92
+ ### 6. Enable linger (Linux servers)
93
+
94
+ systemd user services stop when you log out unless linger is enabled. On any server you SSH out of:
95
+
96
+ ```bash
97
+ sudo loginctl enable-linger $USER
98
+ ```
99
+
100
+ `april install` warns you if it sees this is off.
101
+
102
+ ### 7. Verify
103
+
104
+ ```bash
105
+ april status
27
106
  april logs -f
28
107
  ```
29
108
 
109
+ Healthy logs include `Starting webhook forwarder for <repo>` and no immediate errors. Then label an issue with `agent:todo` and watch it kick off.
110
+
30
111
  ## Commands
31
112
 
32
113
  | Command | What it does |
@@ -39,24 +120,112 @@ april logs -f
39
120
  | `april logs -f [-n N]` | Streams logs. `-n` sets line count (default 100). |
40
121
  | `april daemon` | Runs the worker in the foreground (for debugging; the service uses `dist/index.js` directly). |
41
122
 
123
+ ## Environment variables
124
+
125
+ The daemon reads extra env vars from `~/.config/april/env`. One `KEY=VALUE` per line, `#` for comments, optional double-quotes around values:
126
+
127
+ ```sh
128
+ # ~/.config/april/env
129
+ GH_HOST=your.ghes.host
130
+ GH_ENTERPRISE_TOKEN=ghp_...
131
+ SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
132
+ APRIL_DEBUG=1
133
+ ```
134
+
135
+ This file is seeded by `april install` (and `april init`) and is **never overwritten by reinstalls** — safe to put long-lived secrets here.
136
+
137
+ After editing:
138
+ - **Linux:** `april restart`. The systemd unit re-reads the env file on each start.
139
+ - **macOS:** `april install && april restart`. launchd has no `EnvironmentFile=` equivalent, so values are inlined into the plist at install time; you have to regenerate it after editing.
140
+
141
+ ### Token inheritance
142
+
143
+ `spawner.ts` runs Claude inside tmux with `tmux new-session -d <claudeCommand>` — no login shell. So Claude inherits the daemon's env directly, including any `GH_TOKEN` / `GH_ENTERPRISE_TOKEN` you set in the env file. Practical implication: the PAT you provide has to cover what *both* april and Claude need, not just april. If you want Claude to fall back to your shell's auth (a credential helper, network-level auth, etc.), you'd need to wrap the tmux command in a login shell — not currently supported.
144
+
42
145
  ## Service backend
43
146
 
44
147
  - **Linux** uses systemd user services at `~/.config/systemd/user/april.service`. Logs go to the journal (`journalctl --user -u april`).
45
148
  - **macOS** uses launchd LaunchAgents at `~/Library/LaunchAgents/dev.april.daemon.plist`. Logs go to `~/Library/Logs/april/april.log`.
46
149
 
47
- ### Linux: keep april running after logout
150
+ ### Node version managers
151
+
152
+ `april install` captures the absolute path of the `node` binary it was invoked with (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`) and bakes it into the unit/plist. If you later remove or change that node version, the service will fail to start — re-run `april install` after switching.
153
+
154
+ ## GitHub Enterprise Server caveat
48
155
 
49
- User services stop when you log out unless linger is enabled. On a server you SSH into:
156
+ `gh webhook forward` relies on GitHub's hosted webhook-relay infrastructure. **That relay is github.com-only it is not part of GHES.** If your repos live on a GHES instance, you will see this in the logs after the daemon authenticates fine:
157
+
158
+ ```
159
+ Error: you do not have access to this feature
160
+ ```
161
+
162
+ The webhook extension itself works against GHES (it can authenticate, list extensions, etc.), but the actual `forward` subcommand has no relay to talk to.
163
+
164
+ Workarounds (none currently shipped — file an issue if you need them wired up):
165
+
166
+ 1. **Direct webhook delivery.** Expose april's port publicly (reverse proxy + TLS, or a tunnel like cloudflared / tailscale funnel / ngrok), and configure a webhook on the GHES repo pointing at `https://your-server/webhook/github`. Skip `gh webhook forward` entirely.
167
+ 2. **Polling.** Replace the forwarder with a periodic poll of open issues. april already has reconciliation-on-startup; turning it into a timer is small.
168
+ 3. **Third-party relay** like smee.io.
169
+
170
+ ## Upgrading
50
171
 
51
172
  ```bash
52
- sudo loginctl enable-linger $USER
173
+ april upgrade
53
174
  ```
54
175
 
55
- `april install` reminds you if it sees linger is off.
176
+ This runs the package install, regenerates the unit/plist, and restarts the service in one go. Pass a specific version (`april upgrade 0.0.5`) to pin, or `--with pnpm|yarn` if `april upgrade` picks the wrong package manager.
56
177
 
57
- ### Node version managers
178
+ Manual equivalent if you want to do it yourself:
58
179
 
59
- `april install` captures the absolute path of the `node` binary it was invoked with (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`) and bakes it into the unit/plist. If you later remove or change that node version, the service will fail to start — re-run `april install` after switching.
180
+ ```bash
181
+ npm i -g @armstrongnate/april@latest
182
+ april install # regenerates the unit/plist with any template changes (also runs daemon-reload on Linux)
183
+ april restart
184
+ ```
185
+
186
+ **If you skip `april install` after upgrading, new template features (`EnvironmentFile=`, env-var changes, etc.) will not appear in your existing unit file** — `npm` only updates the package, not anything systemd has on disk.
187
+
188
+ ## Troubleshooting
189
+
190
+ ### `Required gh extension not installed: cli/gh-webhook`
191
+
192
+ Install it as the same user that runs the service:
193
+
194
+ ```bash
195
+ gh extension install cli/gh-webhook
196
+ ```
197
+
198
+ ### `gh auth token not found for host "..."`
199
+
200
+ `gh-webhook` shells out to `gh auth token` to extract a Bearer token, and your gh setup doesn't have an extractable one (you might be relying on a credential helper, network-level auth like Cloudflare Access, or a wrapper script). Add an explicit `GH_TOKEN` / `GH_ENTERPRISE_TOKEN` to `~/.config/april/env`, then `april restart`.
201
+
202
+ ### `you do not have access to this feature` on `gh webhook forward`
203
+
204
+ Your host (likely GHES) doesn't expose the webhook-forwarding relay. See [GitHub Enterprise Server caveat](#github-enterprise-server-caveat).
205
+
206
+ ### Service starts but env vars in `~/.config/april/env` aren't applied
207
+
208
+ You're running a unit that was generated before `EnvironmentFile=` support landed (anything from before v0.0.2). Confirm:
209
+
210
+ ```bash
211
+ systemctl --user cat april | grep -i environment
212
+ ```
213
+
214
+ If you don't see `EnvironmentFile=`, regenerate:
215
+
216
+ ```bash
217
+ april install
218
+ systemctl --user daemon-reload
219
+ april restart
220
+ ```
221
+
222
+ ### `Token:` is empty in `gh auth status` but `gh` commands work
223
+
224
+ Means your auth is provided by something other than a stored token (credential helper, network auth, wrapper). The webhook extension still needs an actual token — it doesn't matter how `gh` does its other API calls. Add `GH_TOKEN` / `GH_ENTERPRISE_TOKEN` to the env file.
225
+
226
+ ### Service can't find `gh` / `tmux` / `claude` even though they're on your shell PATH
227
+
228
+ `april install` captures `$PATH` at install time and bakes it into the unit. If you installed any of those tools after running `april install`, re-run `april install` to recapture PATH.
60
229
 
61
230
  ## Usage
62
231
 
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { backend } from "./service/index.js";
3
3
  import { run as runInit } from "./commands/init.js";
4
+ import { run as runUpgrade } from "./commands/upgrade.js";
4
5
  const HELP = `april — issue worker
5
6
 
6
7
  Usage:
@@ -9,6 +10,8 @@ Usage:
9
10
  Commands:
10
11
  init Copy bundled config + skill to ~/.config/april and ~/.claude
11
12
  install [--print] Install and start the user service. --print emits the unit/plist to stdout instead.
13
+ upgrade [VER] Upgrade the npm package, regenerate the unit, and restart. VER defaults to "latest".
14
+ Pass --with npm|pnpm|yarn to override the auto-detected package manager.
12
15
  uninstall Stop and remove the user service
13
16
  start Start the service
14
17
  stop Stop the service
@@ -70,6 +73,9 @@ async function main() {
70
73
  if (cmd === "init") {
71
74
  return runInit(rest);
72
75
  }
76
+ if (cmd === "upgrade") {
77
+ return runUpgrade(rest);
78
+ }
73
79
  if (cmd === "daemon") {
74
80
  // Run the long-running process inline. Importing index.js triggers main();
75
81
  // we then hang forever and let its SIGINT/SIGTERM handlers terminate the process.
@@ -2,6 +2,8 @@ import { fileURLToPath } from "node:url";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { copyFileSync, existsSync, mkdirSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
+ import { ensureEnvFile, envFilePath } from "../service/envfile.js";
6
+ import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "../precheck.js";
5
7
  // Resolve the bundled package root from this file's installed location.
6
8
  // dist/commands/init.js -> dist/.. (the package root, where config.example.yaml + skills/ live)
7
9
  function packageRoot() {
@@ -37,6 +39,18 @@ export function run(args) {
37
39
  return 1;
38
40
  }
39
41
  copyIfMissing(skillSrc, skillDst, "skill", force);
42
+ const envState = ensureEnvFile();
43
+ console.log(` env: ${envState === "created" ? "wrote" : "already exists"} ${envFilePath()}`);
44
+ console.log("");
45
+ console.log("Checks:");
46
+ if (isGhWebhookExtensionInstalled()) {
47
+ console.log(" ✓ gh extension cli/gh-webhook installed");
48
+ }
49
+ else {
50
+ console.log(" ✗ gh extension cli/gh-webhook NOT installed");
51
+ console.log(` Install with: ${GH_EXTENSION_INSTALL_CMD}`);
52
+ console.log(" (april will refuse to start without it.)");
53
+ }
40
54
  console.log("");
41
55
  if (configResult === "wrote") {
42
56
  console.log(`Next: edit ${configDst}, then run \`april install\`.`);
@@ -0,0 +1,55 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ const PACKAGE = "@armstrongnate/april";
4
+ function detectPackageManager() {
5
+ // Where this script lives reveals which global install dir it's in.
6
+ const here = fileURLToPath(import.meta.url);
7
+ if (/[\\/]\.?pnpm[\\/]|[\\/]Library[\\/]pnpm[\\/]/.test(here))
8
+ return "pnpm";
9
+ if (/[\\/]\.config[\\/]yarn[\\/]|[\\/]\.yarn[\\/]/.test(here))
10
+ return "yarn";
11
+ return "npm";
12
+ }
13
+ function pmInstallArgs(pm, ref) {
14
+ switch (pm) {
15
+ case "pnpm":
16
+ return ["add", "-g", ref];
17
+ case "yarn":
18
+ return ["global", "add", ref];
19
+ case "npm":
20
+ return ["install", "-g", ref];
21
+ }
22
+ }
23
+ function step(name, cmd, args) {
24
+ console.log(`\n→ ${name}`);
25
+ console.log(` $ ${cmd} ${args.join(" ")}`);
26
+ const res = spawnSync(cmd, args, { stdio: "inherit" });
27
+ if ((res.status ?? 1) !== 0) {
28
+ throw new Error(`${name} failed (exit ${res.status})`);
29
+ }
30
+ }
31
+ export function run(args) {
32
+ let pm = detectPackageManager();
33
+ // --with <pm> override
34
+ const withIdx = args.indexOf("--with");
35
+ if (withIdx >= 0) {
36
+ const v = args[withIdx + 1];
37
+ if (v !== "npm" && v !== "pnpm" && v !== "yarn") {
38
+ console.error(`--with must be one of: npm, pnpm, yarn`);
39
+ return 2;
40
+ }
41
+ pm = v;
42
+ }
43
+ let ref = `${PACKAGE}@latest`;
44
+ // Allow `april upgrade <version>` to pin
45
+ const positional = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--with");
46
+ if (positional[0])
47
+ ref = `${PACKAGE}@${positional[0]}`;
48
+ console.log(`april upgrade — using ${pm}, target ${ref}`);
49
+ step(`Installing ${ref}`, pm, pmInstallArgs(pm, ref));
50
+ // From here on, `april` resolves to the freshly installed binary on PATH.
51
+ step("Regenerating service unit (april install)", "april", ["install"]);
52
+ step("Restarting service (april restart)", "april", ["restart"]);
53
+ console.log("\n✓ Upgrade complete. Tail logs with: april logs -f");
54
+ return 0;
55
+ }
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ import { resolve, join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { parse as parseYaml } from "yaml";
6
6
  import { createLogger } from "./logger.js";
7
+ import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "./precheck.js";
7
8
  const log = createLogger("config");
8
9
  function findConfigPath() {
9
10
  const envPath = process.env.APRIL_CONFIG;
@@ -37,6 +38,10 @@ function validateTools() {
37
38
  throw new Error(`Required tool "${tool}" not found on PATH. Install it before running april.`);
38
39
  }
39
40
  }
41
+ if (!isGhWebhookExtensionInstalled()) {
42
+ throw new Error(`Required gh extension not installed: cli/gh-webhook.\n` +
43
+ `Install it with:\n ${GH_EXTENSION_INSTALL_CMD}`);
44
+ }
40
45
  }
41
46
  function validateString(obj, key, context) {
42
47
  const val = obj[key];
@@ -0,0 +1,18 @@
1
+ import { execFileSync } from "node:child_process";
2
+ export const REQUIRED_GH_EXTENSION = "cli/gh-webhook";
3
+ export const GH_EXTENSION_INSTALL_CMD = `gh extension install ${REQUIRED_GH_EXTENSION}`;
4
+ export function isGhWebhookExtensionInstalled() {
5
+ try {
6
+ const out = execFileSync("gh", ["extension", "list"], {
7
+ encoding: "utf-8",
8
+ stdio: ["ignore", "pipe", "pipe"],
9
+ timeout: 10_000,
10
+ });
11
+ // `gh extension list` output includes the extension repo (e.g. cli/gh-webhook)
12
+ // somewhere in each row; substring match is sufficient.
13
+ return out.includes(REQUIRED_GH_EXTENSION);
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
@@ -0,0 +1,48 @@
1
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export function envFilePath() {
5
+ return join(homedir(), ".config", "april", "env");
6
+ }
7
+ const DEFAULT_HEADER = `# april daemon environment variables
8
+ # One KEY=VALUE per line. Lines starting with # are ignored.
9
+ # Values may be wrapped in double quotes if they contain spaces or special chars.
10
+ # After editing, run: april restart
11
+ `;
12
+ /** Create the env file with a friendly header if it doesn't exist. Never overwrites. */
13
+ export function ensureEnvFile() {
14
+ const path = envFilePath();
15
+ if (existsSync(path))
16
+ return "exists";
17
+ mkdirSync(dirname(path), { recursive: true });
18
+ writeFileSync(path, DEFAULT_HEADER, "utf-8");
19
+ return "created";
20
+ }
21
+ /**
22
+ * Parse the env file and return key/value pairs. Used by launchd, which has
23
+ * no native EnvironmentFile= equivalent — values must be inlined into the plist.
24
+ *
25
+ * Format: KEY=VALUE per line. # for comments. Optional double-quotes around the value.
26
+ */
27
+ export function parseEnvFile() {
28
+ const path = envFilePath();
29
+ if (!existsSync(path))
30
+ return {};
31
+ const result = {};
32
+ const raw = readFileSync(path, "utf-8");
33
+ for (const line of raw.split(/\r?\n/)) {
34
+ const trimmed = line.trim();
35
+ if (!trimmed || trimmed.startsWith("#"))
36
+ continue;
37
+ const eq = trimmed.indexOf("=");
38
+ if (eq < 1)
39
+ continue;
40
+ const key = trimmed.slice(0, eq).trim();
41
+ let value = trimmed.slice(eq + 1).trim();
42
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
43
+ value = value.slice(1, -1);
44
+ }
45
+ result[key] = value;
46
+ }
47
+ return result;
48
+ }
@@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process";
3
3
  import { dirname } from "node:path";
4
4
  import { homedir, userInfo } from "node:os";
5
5
  import { daemonEntryPath, nodeBinaryPath, launchdPlistPath, launchdLogPath, launchdLogDir, LAUNCHD_LABEL, } from "./paths.js";
6
+ import { parseEnvFile, ensureEnvFile } from "./envfile.js";
6
7
  function escapeXml(s) {
7
8
  return s
8
9
  .replace(/&/g, "&amp;")
@@ -15,6 +16,16 @@ export function plistContents() {
15
16
  const entry = daemonEntryPath();
16
17
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
17
18
  const log = launchdLogPath();
19
+ // Built-ins always set; user file overrides on conflict.
20
+ const env = {
21
+ PATH: path,
22
+ NODE_ENV: "production",
23
+ HOME: homedir(),
24
+ ...parseEnvFile(),
25
+ };
26
+ const envEntries = Object.entries(env)
27
+ .map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
28
+ .join("\n");
18
29
  return `<?xml version="1.0" encoding="UTF-8"?>
19
30
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
20
31
  <plist version="1.0">
@@ -39,12 +50,7 @@ export function plistContents() {
39
50
  <string>${escapeXml(homedir())}</string>
40
51
  <key>EnvironmentVariables</key>
41
52
  <dict>
42
- <key>PATH</key>
43
- <string>${escapeXml(path)}</string>
44
- <key>NODE_ENV</key>
45
- <string>production</string>
46
- <key>HOME</key>
47
- <string>${escapeXml(homedir())}</string>
53
+ ${envEntries}
48
54
  </dict>
49
55
  <key>StandardOutPath</key>
50
56
  <string>${escapeXml(log)}</string>
@@ -75,6 +81,8 @@ function serviceTarget() {
75
81
  }
76
82
  export function install() {
77
83
  ensureLaunchctl();
84
+ // Seed env file so it's discoverable; values are parsed and inlined into the plist below.
85
+ ensureEnvFile();
78
86
  mkdirSync(launchdLogDir(), { recursive: true });
79
87
  const path = launchdPlistPath();
80
88
  mkdirSync(dirname(path), { recursive: true });
@@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process";
3
3
  import { dirname } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { daemonEntryPath, nodeBinaryPath, systemdUnitPath, SERVICE_NAME, } from "./paths.js";
6
+ import { envFilePath, ensureEnvFile } from "./envfile.js";
6
7
  function runSystemctl(args) {
7
8
  const res = spawnSync("systemctl", ["--user", ...args], { encoding: "utf-8" });
8
9
  return {
@@ -28,6 +29,7 @@ RestartSec=5s
28
29
  WorkingDirectory=${homedir()}
29
30
  Environment=PATH=${path}
30
31
  Environment=NODE_ENV=production
32
+ EnvironmentFile=-${envFilePath()}
31
33
  StandardOutput=journal
32
34
  StandardError=journal
33
35
 
@@ -57,6 +59,8 @@ function lingerEnabled() {
57
59
  }
58
60
  export function install() {
59
61
  ensureSystemctlPresent();
62
+ // Seed env file so EnvironmentFile=- has something to find on first install.
63
+ ensureEnvFile();
60
64
  const path = systemdUnitPath();
61
65
  mkdirSync(dirname(path), { recursive: true });
62
66
  writeFileSync(path, unitContents(), "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@armstrongnate/april",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "She does all the work so you don't have to. Watches GitHub issues and spawns Claude Code sessions to work them.",
5
5
  "type": "module",
6
6
  "bin": {