@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 +180 -11
- package/dist/cli.js +6 -0
- package/dist/commands/init.js +14 -0
- package/dist/commands/upgrade.js +55 -0
- package/dist/config.js +5 -0
- package/dist/precheck.js +18 -0
- package/dist/service/envfile.js +48 -0
- package/dist/service/launchd.js +14 -6
- package/dist/service/systemd.js +4 -0
- package/package.json +1 -1
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
|
-
##
|
|
15
|
+
## Quick install
|
|
15
16
|
|
|
16
17
|
```bash
|
|
17
18
|
npm i -g @armstrongnate/april
|
|
18
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
npm i -g @armstrongnate/april
|
|
59
|
+
april init
|
|
25
60
|
$EDITOR ~/.config/april/config.yaml
|
|
26
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
april upgrade
|
|
53
174
|
```
|
|
54
175
|
|
|
55
|
-
`april
|
|
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
|
-
|
|
178
|
+
Manual equivalent if you want to do it yourself:
|
|
58
179
|
|
|
59
|
-
|
|
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.
|
package/dist/commands/init.js
CHANGED
|
@@ -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];
|
package/dist/precheck.js
ADDED
|
@@ -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
|
+
}
|
package/dist/service/launchd.js
CHANGED
|
@@ -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, "&")
|
|
@@ -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
|
-
|
|
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 });
|
package/dist/service/systemd.js
CHANGED
|
@@ -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