@armstrongnate/april 0.0.1 → 0.0.2

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,6 +8,7 @@ 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
 
@@ -21,12 +22,14 @@ npm i -g @armstrongnate/april
21
22
  Then:
22
23
 
23
24
  ```bash
24
- april init # writes ~/.config/april/config.yaml + installs the issue-worker skill
25
+ april init # writes ~/.config/april/config.yaml + skill + env file; checks prereqs
25
26
  $EDITOR ~/.config/april/config.yaml
26
27
  april install # registers the user service and starts it
27
28
  april logs -f
28
29
  ```
29
30
 
31
+ `april init` and the daemon both verify that the `cli/gh-webhook` extension is installed and refuse to proceed without it.
32
+
30
33
  ## Commands
31
34
 
32
35
  | Command | What it does |
@@ -39,6 +42,22 @@ april logs -f
39
42
  | `april logs -f [-n N]` | Streams logs. `-n` sets line count (default 100). |
40
43
  | `april daemon` | Runs the worker in the foreground (for debugging; the service uses `dist/index.js` directly). |
41
44
 
45
+ ## Environment variables
46
+
47
+ The daemon reads extra env vars from `~/.config/april/env`. One `KEY=VALUE` per line, `#` for comments, optional double-quotes around values:
48
+
49
+ ```sh
50
+ # ~/.config/april/env
51
+ SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
52
+ APRIL_DEBUG=1
53
+ GREETING="hello world"
54
+ ```
55
+
56
+ This file is seeded by `april install` (and `april init`) and is **never overwritten by reinstalls** — safe to put long-lived secrets here. After editing, `april restart`.
57
+
58
+ - On Linux, the systemd unit references it via `EnvironmentFile=-`. Changes take effect on the next restart.
59
+ - On macOS, launchd has no equivalent directive, so values are read at install time and inlined into the plist. After editing the env file, run `april install` to regenerate the plist, then `april restart`.
60
+
42
61
  ## Service backend
43
62
 
44
63
  - **Linux** uses systemd user services at `~/.config/systemd/user/april.service`. Logs go to the journal (`journalctl --user -u april`).
@@ -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\`.`);
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.2",
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": {