@armstrongnate/april 0.0.4 → 0.0.8

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
@@ -112,8 +112,10 @@ Healthy logs include `Starting webhook forwarder for <repo>` and no immediate er
112
112
 
113
113
  | Command | What it does |
114
114
  | --- | --- |
115
- | `april init` | Copies the bundled `config.example.yaml` to `~/.config/april/config.yaml` and the `issue-worker` skill to `~/.claude/skills/`. Won't overwrite without `--force`. |
115
+ | `april init` | Copies the bundled `config.example.yaml` to `~/.config/april/config.yaml` and the `issue-worker` skill to `~/.claude/skills/`. **Only writes files that don't already exist** never overwrites. |
116
116
  | `april install` | Installs and starts the user service. Pass `--print` to see the unit/plist without writing it. |
117
+ | `april install-skill [-y]` | Install or refresh the issue-worker skill. Prompts before overwriting an existing copy; `--yes` skips the prompt (use in non-interactive scripts). |
118
+ | `april upgrade [VER]` | Upgrade the npm package, regenerate the unit, restart the service, and reconcile the skill. |
117
119
  | `april uninstall` | Stops and removes the service. |
118
120
  | `april start` / `stop` / `restart` | Lifecycle. |
119
121
  | `april status` | Shows service status. |
@@ -147,6 +149,12 @@ After editing:
147
149
  - **Linux** uses systemd user services at `~/.config/systemd/user/april.service`. Logs go to the journal (`journalctl --user -u april`).
148
150
  - **macOS** uses launchd LaunchAgents at `~/Library/LaunchAgents/dev.april.daemon.plist`. Logs go to `~/Library/Logs/april/april.log`.
149
151
 
152
+ ### Restarts and tmux sessions
153
+
154
+ The unit/plist sets `KillMode=process` (systemd) / `AbandonProcessGroup=true` (launchd), which means `april restart` only signals the daemon itself — tmux sessions and the Claude processes inside them keep running. The daemon's shutdown handler explicitly terminates the `gh webhook forward` children before exiting.
155
+
156
+ If the daemon ever has to be SIGKILLed (it hung past the systemd stop timeout), the forwarders will be orphaned to PID 1. Clean them up with `pkill -f 'gh webhook forward'`.
157
+
150
158
  ### Node version managers
151
159
 
152
160
  `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.
@@ -185,6 +193,16 @@ april restart
185
193
 
186
194
  **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
195
 
196
+ `april upgrade` ends by running `april install-skill`, which:
197
+
198
+ - silently installs the skill if missing,
199
+ - says "already up to date" if your installed copy matches the bundled one, or
200
+ - **prompts you** before overwriting if your copy differs (in case you customized it).
201
+
202
+ For non-interactive scripts, pass `--yes` to `april install-skill` (or to the upgrade flow indirectly: `april install-skill -y`).
203
+
204
+ Your `~/.config/april/config.yaml` is never overwritten by any `april` command. To reset it from the example, delete the file manually and re-run `april init`.
205
+
188
206
  ## Troubleshooting
189
207
 
190
208
  ### `Required gh extension not installed: cli/gh-webhook`
package/dist/cli.js CHANGED
@@ -2,16 +2,19 @@
2
2
  import { backend } from "./service/index.js";
3
3
  import { run as runInit } from "./commands/init.js";
4
4
  import { run as runUpgrade } from "./commands/upgrade.js";
5
+ import { run as runInstallSkill } from "./commands/install-skill.js";
5
6
  const HELP = `april — issue worker
6
7
 
7
8
  Usage:
8
9
  april <command> [options]
9
10
 
10
11
  Commands:
11
- init Copy bundled config + skill to ~/.config/april and ~/.claude
12
+ init Copy bundled config + skill to ~/.config/april and ~/.claude (only if missing).
12
13
  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.
14
+ install-skill [-y] Install or refresh the issue-worker skill. Prompts before overwriting an existing
15
+ one; --yes (-y) skips the prompt.
16
+ upgrade [VER] Upgrade the npm package, regenerate the unit, restart, and reconcile the skill.
17
+ VER defaults to "latest". --with npm|pnpm|yarn overrides the package manager.
15
18
  uninstall Stop and remove the user service
16
19
  start Start the service
17
20
  stop Stop the service
@@ -22,8 +25,9 @@ Commands:
22
25
  help Show this help
23
26
  version Show version
24
27
 
25
- Options for init:
26
- --force, -f Overwrite existing files
28
+ Notes:
29
+ Nothing is ever overwritten silently. To reset config, delete ~/.config/april/config.yaml
30
+ and re-run init. To refresh the skill, use install-skill (it prompts before overwriting).
27
31
  `;
28
32
  function parseLogsArgs(args) {
29
33
  let follow = false;
@@ -76,6 +80,9 @@ async function main() {
76
80
  if (cmd === "upgrade") {
77
81
  return runUpgrade(rest);
78
82
  }
83
+ if (cmd === "install-skill") {
84
+ return await runInstallSkill(rest);
85
+ }
79
86
  if (cmd === "daemon") {
80
87
  // Run the long-running process inline. Importing index.js triggers main();
81
88
  // we then hang forever and let its SIGINT/SIGTERM handlers terminate the process.
@@ -4,24 +4,24 @@ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { ensureEnvFile, envFilePath } from "../service/envfile.js";
6
6
  import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "../precheck.js";
7
+ import { SKILL_DST, bundledSkillPath, compareSkill } from "../skill.js";
7
8
  // Resolve the bundled package root from this file's installed location.
8
- // dist/commands/init.js -> dist/.. (the package root, where config.example.yaml + skills/ live)
9
+ // dist/commands/init.js -> dist/.. (the package root, where config.example.yaml lives)
9
10
  function packageRoot() {
10
11
  const here = fileURLToPath(import.meta.url);
11
12
  return resolve(dirname(here), "..", "..");
12
13
  }
13
- function copyIfMissing(src, dst, label, force) {
14
+ function copyIfMissing(src, dst, label) {
14
15
  mkdirSync(dirname(dst), { recursive: true });
15
- if (existsSync(dst) && !force) {
16
- console.log(` ${label}: already exists at ${dst} (use --force to overwrite)`);
16
+ if (existsSync(dst)) {
17
+ console.log(` ${label}: already exists at ${dst}`);
17
18
  return "exists";
18
19
  }
19
20
  copyFileSync(src, dst);
20
21
  console.log(` ${label}: wrote ${dst}`);
21
22
  return "wrote";
22
23
  }
23
- export function run(args) {
24
- const force = args.includes("--force") || args.includes("-f");
24
+ export function run(_args) {
25
25
  const root = packageRoot();
26
26
  console.log("april init");
27
27
  console.log("");
@@ -31,14 +31,13 @@ export function run(args) {
31
31
  console.error(` Cannot find bundled config.example.yaml at ${configSrc}`);
32
32
  return 1;
33
33
  }
34
- const configResult = copyIfMissing(configSrc, configDst, "config", force);
35
- const skillSrc = join(root, "skills", "issue-worker", "SKILL.md");
36
- const skillDst = join(homedir(), ".claude", "skills", "issue-worker", "SKILL.md");
34
+ const configResult = copyIfMissing(configSrc, configDst, "config");
35
+ const skillSrc = bundledSkillPath();
37
36
  if (!existsSync(skillSrc)) {
38
37
  console.error(` Cannot find bundled skill at ${skillSrc}`);
39
38
  return 1;
40
39
  }
41
- copyIfMissing(skillSrc, skillDst, "skill", force);
40
+ copyIfMissing(skillSrc, SKILL_DST, "skill");
42
41
  const envState = ensureEnvFile();
43
42
  console.log(` env: ${envState === "created" ? "wrote" : "already exists"} ${envFilePath()}`);
44
43
  console.log("");
@@ -51,6 +50,11 @@ export function run(args) {
51
50
  console.log(` Install with: ${GH_EXTENSION_INSTALL_CMD}`);
52
51
  console.log(" (april will refuse to start without it.)");
53
52
  }
53
+ // If the installed skill differs from what we shipped, surface it without prompting.
54
+ if (compareSkill() === "differs-from-bundled") {
55
+ console.log("");
56
+ console.log(` i installed skill differs from bundled. Refresh with: april install-skill`);
57
+ }
54
58
  console.log("");
55
59
  if (configResult === "wrote") {
56
60
  console.log(`Next: edit ${configDst}, then run \`april install\`.`);
@@ -0,0 +1,50 @@
1
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { stdin, stdout } from "node:process";
5
+ import { SKILL_DST, bundledSkillPath, compareSkill } from "../skill.js";
6
+ export async function run(args) {
7
+ const yes = args.includes("--yes") || args.includes("-y");
8
+ const src = bundledSkillPath();
9
+ if (!existsSync(src)) {
10
+ console.error(`Cannot find bundled skill at ${src}`);
11
+ return 1;
12
+ }
13
+ const state = compareSkill();
14
+ if (state === "matches-bundled") {
15
+ console.log(`✓ Skill at ${SKILL_DST} is already up to date`);
16
+ return 0;
17
+ }
18
+ if (state === "missing") {
19
+ mkdirSync(dirname(SKILL_DST), { recursive: true });
20
+ copyFileSync(src, SKILL_DST);
21
+ console.log(`✓ Installed skill at ${SKILL_DST}`);
22
+ return 0;
23
+ }
24
+ // differs-from-bundled
25
+ console.log("Bundled issue-worker skill differs from the installed copy.");
26
+ console.log(` installed: ${SKILL_DST}`);
27
+ console.log(` bundled: ${src}`);
28
+ console.log("");
29
+ console.log(`Diff with: diff ${SKILL_DST} ${src}`);
30
+ console.log("");
31
+ if (!yes) {
32
+ if (!stdin.isTTY) {
33
+ console.error("Refusing to overwrite without confirmation in a non-interactive session.\n" +
34
+ "Re-run with --yes to confirm, or run interactively to be prompted.");
35
+ return 1;
36
+ }
37
+ const rl = createInterface({ input: stdin, output: stdout });
38
+ const answer = (await rl.question("Overwrite installed skill with bundled version? [y/N] "))
39
+ .trim()
40
+ .toLowerCase();
41
+ rl.close();
42
+ if (answer !== "y" && answer !== "yes") {
43
+ console.log("Skipped — installed skill is unchanged.");
44
+ return 0;
45
+ }
46
+ }
47
+ copyFileSync(src, SKILL_DST);
48
+ console.log(`✓ Overwrote skill at ${SKILL_DST}`);
49
+ return 0;
50
+ }
@@ -2,7 +2,6 @@ import { spawnSync } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
3
  const PACKAGE = "@armstrongnate/april";
4
4
  function detectPackageManager() {
5
- // Where this script lives reveals which global install dir it's in.
6
5
  const here = fileURLToPath(import.meta.url);
7
6
  if (/[\\/]\.?pnpm[\\/]|[\\/]Library[\\/]pnpm[\\/]/.test(here))
8
7
  return "pnpm";
@@ -30,7 +29,6 @@ function step(name, cmd, args) {
30
29
  }
31
30
  export function run(args) {
32
31
  let pm = detectPackageManager();
33
- // --with <pm> override
34
32
  const withIdx = args.indexOf("--with");
35
33
  if (withIdx >= 0) {
36
34
  const v = args[withIdx + 1];
@@ -41,7 +39,6 @@ export function run(args) {
41
39
  pm = v;
42
40
  }
43
41
  let ref = `${PACKAGE}@latest`;
44
- // Allow `april upgrade <version>` to pin
45
42
  const positional = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--with");
46
43
  if (positional[0])
47
44
  ref = `${PACKAGE}@${positional[0]}`;
@@ -50,6 +47,7 @@ export function run(args) {
50
47
  // From here on, `april` resolves to the freshly installed binary on PATH.
51
48
  step("Regenerating service unit (april install)", "april", ["install"]);
52
49
  step("Restarting service (april restart)", "april", ["restart"]);
50
+ step("Reconciling skill (april install-skill)", "april", ["install-skill"]);
53
51
  console.log("\n✓ Upgrade complete. Tail logs with: april logs -f");
54
52
  return 0;
55
53
  }
@@ -58,6 +58,11 @@ ${envEntries}
58
58
  <string>${escapeXml(log)}</string>
59
59
  <key>ProcessType</key>
60
60
  <string>Background</string>
61
+ <!-- launchd's equivalent of systemd's KillMode=process: when the daemon
62
+ exits, don't kill children that share its process group. Keeps tmux
63
+ sessions and any in-flight Claude work alive across restarts. -->
64
+ <key>AbandonProcessGroup</key>
65
+ <true/>
61
66
  </dict>
62
67
  </plist>
63
68
  `;
@@ -32,6 +32,10 @@ Environment=NODE_ENV=production
32
32
  EnvironmentFile=-${envFilePath()}
33
33
  StandardOutput=journal
34
34
  StandardError=journal
35
+ # Only signal the main process on stop; leave tmux sessions and any other
36
+ # children running so an april restart doesn't trash in-flight Claude work.
37
+ # The daemon's own shutdown handler still SIGTERMs the gh webhook forwarders.
38
+ KillMode=process
35
39
 
36
40
  [Install]
37
41
  WantedBy=default.target
package/dist/skill.js ADDED
@@ -0,0 +1,20 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ export const SKILL_DST = join(homedir(), ".claude", "skills", "issue-worker", "SKILL.md");
6
+ export function bundledSkillPath() {
7
+ // dist/skill.js -> dist/.. -> package root, then skills/issue-worker/SKILL.md
8
+ const here = fileURLToPath(import.meta.url);
9
+ return resolve(dirname(here), "..", "skills", "issue-worker", "SKILL.md");
10
+ }
11
+ export function compareSkill() {
12
+ if (!existsSync(SKILL_DST))
13
+ return "missing";
14
+ const src = bundledSkillPath();
15
+ if (!existsSync(src))
16
+ return "matches-bundled"; // can't compare; don't alarm
17
+ return readFileSync(SKILL_DST).equals(readFileSync(src))
18
+ ? "matches-bundled"
19
+ : "differs-from-bundled";
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@armstrongnate/april",
3
- "version": "0.0.4",
3
+ "version": "0.0.8",
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": {