@alfe.ai/gateway 0.8.0 → 0.8.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.
Files changed (2) hide show
  1. package/dist/upgrade.js +122 -2
  2. package/package.json +3 -3
package/dist/upgrade.js CHANGED
@@ -30,7 +30,7 @@ import { existsSync } from "node:fs";
30
30
  * This module holds only pure decision logic + a thin exec wrapper so the
31
31
  * decision can be unit-tested without a real install on disk.
32
32
  */
33
- const execFileAsync$1 = promisify(execFile);
33
+ const execFileAsync$2 = promisify(execFile);
34
34
  /**
35
35
  * Pure verification decision. Given a probe of the install on disk, decide
36
36
  * whether it is intact. When `expectedVersion` is provided, the reported
@@ -96,7 +96,7 @@ async function resolveCliEntryPath(fromUrl) {
96
96
  */
97
97
  async function tryVersion(cmd, args) {
98
98
  try {
99
- const { stdout } = await execFileAsync$1(cmd, args, { timeout: 15e3 });
99
+ const { stdout } = await execFileAsync$2(cmd, args, { timeout: 15e3 });
100
100
  const out = stdout.trim();
101
101
  return out.length > 0 ? out : null;
102
102
  } catch {
@@ -127,6 +127,121 @@ async function verifyCliInstall(expectedVersion, entryPath) {
127
127
  }, expectedVersion);
128
128
  }
129
129
  //#endregion
130
+ //#region src/orphan-cleanup.ts
131
+ /**
132
+ * Orphan npm-reify temp-dir cleanup — makes CLI self-upgrade ENOTEMPTY-safe.
133
+ *
134
+ * When npm installs a global package it uses `reify`, which RENAMES the existing
135
+ * package dir to a `.`-prefixed temp sibling before swapping in the new tree:
136
+ *
137
+ * /usr/lib/node_modules/@alfe.ai/cli → /usr/lib/node_modules/@alfe.ai/.cli-<hash>
138
+ *
139
+ * If that install is KILLED mid-reify — exactly what happens when the daemon
140
+ * self-updates by running `npm install -g @alfe.ai/cli@<v>` out of its OWN live
141
+ * dir and systemd SIGKILLs it — the `.cli-<hash>` temp is left ORPHANED. The next
142
+ * install then fails with:
143
+ *
144
+ * ENOTEMPTY: rename '.../@alfe.ai/cli' → '.../@alfe.ai/.cli-<hash>'
145
+ *
146
+ * because npm's rename TARGET already exists. This bricked GoldRush's upgrade
147
+ * (see `.claude/agent-memory/agent-runtime-engineer/incident_2026_07_13_...`).
148
+ *
149
+ * Fix: sweep stale `.`-prefixed reify temps for our package BEFORE running the
150
+ * install. Guarded to only ever remove `.`-prefixed temp SIBLINGS of the real
151
+ * package (`.cli-*`), never the real `cli` dir and never unrelated packages.
152
+ *
153
+ * This module keeps the SELECTION logic pure (`findOrphanReifyTempDirs`) so it is
154
+ * unit-testable without touching the filesystem; the side-effecting sweep is a
155
+ * thin wrapper.
156
+ */
157
+ const execFileAsync$1 = promisify(execFile);
158
+ /** The npm scope dir the CLI + gateway live under (`@alfe.ai`). */
159
+ const CLI_SCOPE = "@alfe.ai";
160
+ /**
161
+ * Pure: given the directory entries SIBLING to the real package dir, return the
162
+ * `.`-prefixed reify temps that belong to `<packageBasename>` — i.e. entries
163
+ * matching `.<packageBasename>-*`. Excludes the real dir (`<packageBasename>`),
164
+ * the bare `.<packageBasename>` (no `-<hash>` suffix — never a reify temp), and
165
+ * any temp for a different package.
166
+ */
167
+ function findOrphanReifyTempDirs(siblings, packageBasename) {
168
+ const prefix = `.${packageBasename}-`;
169
+ return siblings.filter((name) => name.startsWith(prefix));
170
+ }
171
+ /**
172
+ * Resolve the npm global scope dir (`<globalNodeModules>/@alfe.ai`) that holds
173
+ * the CLI package and, potentially, its orphaned reify temps.
174
+ *
175
+ * Detection order (robust — never hardcode `/usr/lib`):
176
+ * 1. `npm root -g` → the global `node_modules` root. Authoritative on the box.
177
+ * 2. Derive from the resolved CLI entry path (`.../node_modules/@alfe.ai/cli/
178
+ * dist/index.js`) by walking up to the `node_modules` segment.
179
+ *
180
+ * Returns undefined when neither resolves (dev checkout / non-global run); the
181
+ * caller then skips the sweep (there is no global install to clean).
182
+ */
183
+ async function resolveGlobalScopeDir(cliEntryPath) {
184
+ const { join, sep } = await import("node:path");
185
+ try {
186
+ const { stdout } = await execFileAsync$1("npm", ["root", "-g"], { timeout: 15e3 });
187
+ const root = stdout.trim();
188
+ if (root.length > 0) return join(root, CLI_SCOPE);
189
+ } catch {}
190
+ if (cliEntryPath) {
191
+ const marker = `${sep}node_modules${sep}`;
192
+ const idx = cliEntryPath.lastIndexOf(marker);
193
+ if (idx !== -1) return join(cliEntryPath.slice(0, idx + marker.length - 1), CLI_SCOPE);
194
+ }
195
+ }
196
+ /**
197
+ * Sweep orphaned `.cli-*` reify temp dirs from the CLI's global scope dir before
198
+ * a self-upgrade. Best-effort and non-throwing: a cleanup failure must never
199
+ * block the upgrade (worst case, the install ENOTEMPTYs and we're no worse off
200
+ * than before this defense existed).
201
+ *
202
+ * @param scopeDir the `<globalNodeModules>/@alfe.ai` dir (from
203
+ * {@link resolveGlobalScopeDir}). When undefined, no-op.
204
+ */
205
+ async function sweepOrphanReifyTempDirs(scopeDir) {
206
+ if (!scopeDir) {
207
+ logger.debug("Orphan sweep skipped — could not resolve global scope dir");
208
+ return;
209
+ }
210
+ try {
211
+ const { readdir, rm } = await import("node:fs/promises");
212
+ const { join } = await import("node:path");
213
+ let siblings;
214
+ try {
215
+ siblings = await readdir(scopeDir);
216
+ } catch {
217
+ return;
218
+ }
219
+ const orphans = findOrphanReifyTempDirs(siblings, "cli");
220
+ if (orphans.length === 0) return;
221
+ logger.warn({
222
+ scopeDir,
223
+ orphans
224
+ }, "Removing orphaned npm reify temp dirs before upgrade (ENOTEMPTY guard)");
225
+ for (const orphan of orphans) {
226
+ const target = join(scopeDir, orphan);
227
+ try {
228
+ await rm(target, {
229
+ recursive: true,
230
+ force: true
231
+ });
232
+ logger.info({ target }, "Removed orphaned reify temp dir");
233
+ } catch (err) {
234
+ logger.warn({
235
+ err: err instanceof Error ? err.message : String(err),
236
+ target
237
+ }, "Failed to remove orphaned reify temp dir (non-fatal)");
238
+ }
239
+ }
240
+ } catch (err) {
241
+ logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Orphan reify temp-dir sweep failed (non-fatal)");
242
+ }
243
+ }
244
+ //#endregion
130
245
  //#region src/upgrade.ts
131
246
  /**
132
247
  * CLI upgrade — runs npm install inline then exits.
@@ -160,6 +275,11 @@ const execFileAsync = promisify(execFile);
160
275
  */
161
276
  async function upgradeAndExit(version) {
162
277
  logger.info({ version }, "Upgrading CLI...");
278
+ try {
279
+ await sweepOrphanReifyTempDirs(await resolveGlobalScopeDir(await resolveCliEntryPath(import.meta.url)));
280
+ } catch (err) {
281
+ logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Orphan reify-temp sweep threw unexpectedly — proceeding with upgrade anyway");
282
+ }
163
283
  try {
164
284
  const { stdout, stderr } = await execFileAsync("npm", [
165
285
  "install",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/gateway",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Alfe local gateway daemon — persistent control plane for agent integrations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,9 +25,9 @@
25
25
  "ws": "^8.18.0",
26
26
  "@alfe.ai/agent-api-client": "^0.11.0",
27
27
  "@alfe.ai/ai-proxy-local": "^0.0.13",
28
+ "@alfe.ai/integration-manifest": "^0.3.2",
28
29
  "@alfe.ai/config": "^0.3.0",
29
- "@alfe.ai/integration-manifest": "^0.3.1",
30
- "@alfe.ai/integrations": "^0.5.0",
30
+ "@alfe.ai/integrations": "^0.5.1",
31
31
  "@alfe.ai/mcp-bundler": "^0.3.1"
32
32
  },
33
33
  "license": "UNLICENSED",