@ijfw/install 1.5.4 → 1.5.5

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
@@ -1,92 +1,60 @@
1
- # @ijfw/install
1
+ # @ijfw/install — One-command installer for IJFW
2
2
 
3
- One-command installer for [IJFW](https://gitlab.com/therealseandonahoe/ijfw) -- the AI
4
- efficiency layer for 15 AI coding agents: Claude Code, Codex, Gemini, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, Qwen Code, Cline, Kimi Code, OpenClaw, Antigravity, and Aider.
3
+ > Ferrox Labs · Local-first infrastructure for AI coding agents
4
+
5
+ IJFW is Ferrox Labs' shared development infrastructure for AI-driven teams — shared memory across projects, smart model routing, cross-AI adversarial audits (Trident: Claude + Codex + Gemini in parallel), and a disciplined think-build-ship workflow. v1.5.5 closed 60 audit findings in a single milestone. This package wires it onto every AI coding agent on your machine.
6
+
7
+ Full docs: [github.com/FerroxLabs/ijfw](https://github.com/FerroxLabs/ijfw)
8
+
9
+ ## Table of contents
10
+
11
+ - [Install](#install)
12
+ - [What it does](#what-it-does)
13
+ - [Options](#options)
14
+ - [Uninstall](#uninstall)
15
+ - [Links](#links)
5
16
 
6
17
  ## Install
7
18
 
8
19
  ```bash
9
20
  npm install -g @ijfw/install
10
- ijfw demo
21
+ ijfw install
11
22
  ```
12
23
 
13
- IJFW configures every agent on your machine. The options below let you customize the install location, branch, or skip specific steps -- all are optional.
24
+ If no AI agents are detected, install Claude Code or Codex first, then re-run.
25
+
26
+ ## What it does
14
27
 
15
- ### Options
28
+ - Installs the IJFW source tree at `~/.ijfw/`
29
+ - Wires the MCP server into 14 platforms via their config files
30
+ - Adds Aider as a rules-only tier — 15 agents supported total
31
+ - Sets up shared memory at `~/.ijfw/memory/` (plain markdown hot, SQLite FTS5 warm, optional vectors cold)
32
+ - Runs an 8-gate preflight before declaring done
33
+
34
+ ## Options
16
35
 
17
36
  | Flag | Default | Notes |
18
37
  |------|---------|-------|
19
38
  | `--dir <path>` | `$IJFW_HOME` or `~/.ijfw` | Install location |
20
39
  | `--branch <name>` | latest released tag | Git branch or tag |
21
- | `--no-marketplace` | off | Skip settings.json edits |
22
- | `--yes` | off | Non-interactive |
40
+ | `--no-marketplace` | enabled | Skip settings.json edits |
41
+ | `--yes` | interactive | Non-interactive run |
23
42
 
24
- ### Uninstall
43
+ ## Uninstall
25
44
 
26
45
  ```bash
27
46
  ijfw uninstall # preserves ~/.ijfw/memory/
28
47
  ijfw uninstall --purge # removes memory too
29
48
  ```
30
49
 
31
- If `ijfw` isn't on your PATH (e.g. you uninstalled the global `@ijfw/install`
32
- package already), invoke the bin directly:
50
+ If `ijfw` is no longer on your PATH, invoke the bin directly:
33
51
 
34
52
  ```bash
35
53
  npx -p @ijfw/install ijfw-uninstall
36
54
  ```
37
55
 
38
- Memory is preserved across re-runs by default.
39
-
40
- ## Preflight
41
-
42
- Requires `node >=18` and `git` (used for the initial repo clone). The
43
- installer is Node-native end to end -- no bash, no WSL, no Git for Windows
44
- shell. On native Windows use the PowerShell installer (PS 5.1+), which
45
- delegates to Node directly:
46
-
47
- ```powershell
48
- iwr https://gitlab.com/therealseandonahoe/ijfw/-/raw/main/installer/src/install.ps1 -OutFile install.ps1
49
- .\install.ps1 -Dir $env:USERPROFILE\.ijfw
50
- ```
51
-
52
- ## Extension CLI
53
-
54
- IJFW ships a full extension system for installing and sandboxing third-party skills.
55
-
56
- ```bash
57
- # Publisher key management
58
- ijfw extension keygen <author> # Generate an Ed25519 publisher keypair
59
- ijfw extension trust <keyId> <publicKey> # Add a publisher to your trusted store
60
- ijfw extension trust-registry [<url>] # Pull + apply the hosted publisher registry
61
- ijfw extension untrust <keyId> # Remove a publisher from your trusted store
62
- ijfw extension trusted # List all trusted publishers
63
-
64
- # Extension lifecycle
65
- ijfw extension add <source> [flags] # Install an extension (npm name, local path, or https git URL)
66
- --allow-unsigned # Accept extensions with no signature
67
- --accept-untrusted # Accept extensions signed by an untrusted publisher (prompts on TTY)
68
- --activate # Auto-activate after install
69
- ijfw extension activate <name> # Activate an installed extension (enforces declared permissions)
70
- ijfw extension deactivate # Deactivate the current extension
71
-
72
- # Admin / registry maintainer (rare)
73
- ijfw extension rotate-keys <oldKeyId> <newKeyId> # Produce a signed rotation token
74
- ijfw extension keygen-meta <author> # Generate the registry meta-keypair
75
- ijfw extension sign-registry <path> # Sign a registry JSON file in place
76
- ijfw extension verify-registry <path> # Verify a registry JSON signature
77
- ijfw extension registry-status # Show registry cache age + signature status
78
- ```
79
-
80
- The rotation flow and registry maintainer docs live in `docs/REGISTRY-MAINTAINER.md`.
81
-
82
- ## Build (contributors)
83
-
84
- ```bash
85
- cd installer
86
- npm install
87
- npm run build # outputs dist/install.js + dist/uninstall.js
88
- npm test
89
- npm run pack:check
90
- ```
56
+ ## Links
91
57
 
92
- Tarball target: **<100 KB**.
58
+ - Source, issues, and docs: [github.com/FerroxLabs/ijfw](https://github.com/FerroxLabs/ijfw)
59
+ - npm package: [@ijfw/install](https://www.npmjs.com/package/@ijfw/install)
60
+ - License: MIT
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "ijfw",
3
3
  "displayName": "IJFW — AI Efficiency Layer",
4
- "version": "1.5.4",
4
+ "version": "1.5.5",
5
5
  "description": "One install, every AI coding agent, zero config. Unifies 15 CLIs under a shared MCP memory layer so context follows you across Claude, Codex, Gemini, Cursor, Windsurf, and 10 more.",
6
6
  "author": "Sean Donahoe",
7
7
  "icon": "assets/ijfw-logo.svg",
8
8
  "dist": {
9
- "tarball": "extensions/ijfw-1.5.4.zip",
10
- "integrity": "sha512-VzLLJg/kN1vP4kp6uSe26PJi92JoxT0rgLyulG7JLFuqr9AetZKIQBSBD/d4qWMP5q0k7Mn0J4eI7uZl27pfFw==",
11
- "unpackedSize": 3205
9
+ "tarball": "extensions/ijfw-1.5.5.zip",
10
+ "integrity": "sha512-vm3ot0+GhXYy0/HcStB/xmFjZhSXWG7jzSLuIPe43/Dla8qhyR9gsCwBI/8+RsUJqMXiPgc0GuxrgwW2f5DDWA==",
11
+ "unpackedSize": 3312
12
12
  },
13
13
  "engines": {
14
14
  "wayland": ">=0.6.0"
package/dist/ijfw.js CHANGED
@@ -2110,7 +2110,7 @@ __export(upgrade_smoke_exports, {
2110
2110
  severity: () => severity11
2111
2111
  });
2112
2112
  import { spawnSync as spawnSync11 } from "node:child_process";
2113
- import { mkdtempSync as mkdtempSync3, rmSync as rmSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync5, readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
2113
+ import { mkdtempSync as mkdtempSync3, rmSync as rmSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync5, readFileSync as readFileSync4, existsSync as existsSync4, cpSync } from "node:fs";
2114
2114
  import { join as join10, resolve as resolve2 } from "node:path";
2115
2115
  import { tmpdir as tmpdir3 } from "node:os";
2116
2116
  async function run11(ctx) {
@@ -2199,30 +2199,94 @@ async function run11(ctx) {
2199
2199
  durationMs: Date.now() - t0
2200
2200
  };
2201
2201
  }
2202
- const settingsPath = join10(claudeDir, "settings.json");
2203
- if (existsSync4(settingsPath)) {
2204
- let settings;
2205
- try {
2206
- settings = JSON.parse(readFileSync4(settingsPath, "utf8"));
2207
- } catch (e) {
2208
- return {
2209
- name: "upgrade-smoke",
2210
- status: "FAIL",
2211
- message: "upgrade-smoke: settings.json is not valid JSON",
2212
- details: [e.message],
2213
- durationMs: Date.now() - t0
2214
- };
2202
+ const targetIjfwHome = join10(fakeHome, ".ijfw");
2203
+ mkdirSync4(targetIjfwHome, { recursive: true });
2204
+ for (const sub of ["claude", "mcp-server"]) {
2205
+ const src = join10(ctx.repoRoot, sub);
2206
+ if (existsSync4(src)) {
2207
+ cpSync(src, join10(targetIjfwHome, sub), { recursive: true });
2215
2208
  }
2216
- const hasWrongKey = JSON.stringify(settings).includes("ijfw-core");
2217
- if (hasWrongKey) {
2218
- return {
2219
- name: "upgrade-smoke",
2220
- status: "FAIL",
2221
- message: 'upgrade-smoke: settings.json still uses deprecated "ijfw-core" key',
2222
- details: [`Found "ijfw-core" in: ${settingsPath}`],
2223
- durationMs: Date.now() - t0
2224
- };
2209
+ }
2210
+ const installerPkgSrc = join10(ctx.repoRoot, "installer", "package.json");
2211
+ if (existsSync4(installerPkgSrc)) {
2212
+ mkdirSync4(join10(targetIjfwHome, "installer"), { recursive: true });
2213
+ cpSync(installerPkgSrc, join10(targetIjfwHome, "installer", "package.json"));
2214
+ }
2215
+ const runInstaller = spawnSync11(installerBin, ["--yes"], {
2216
+ encoding: "utf8",
2217
+ cwd: installDir,
2218
+ timeout: 12e4,
2219
+ env: {
2220
+ ...cleanEnv,
2221
+ HOME: fakeHome,
2222
+ USERPROFILE: fakeHome,
2223
+ IJFW_HOME: targetIjfwHome,
2224
+ // Hermetic: install.js refuses any network attempt under this flag
2225
+ // (TR-001). The gate's contract is "the installer either completes
2226
+ // without network or fails clearly". The marketplace merge step
2227
+ // (which is what we actually want to verify) does NOT need network.
2228
+ CI: "1",
2229
+ IJFW_SKIP_NETWORK: "1"
2230
+ },
2231
+ shell: process.platform === "win32"
2232
+ });
2233
+ if (runInstaller.status !== 0 || runInstaller.signal) {
2234
+ const stderrLines = (runInstaller.stderr || "").split("\n").filter(Boolean);
2235
+ const lastStderrLine = stderrLines.length > 0 ? stderrLines[stderrLines.length - 1].slice(0, 200) : "(no stderr)";
2236
+ let cat;
2237
+ if (runInstaller.signal) {
2238
+ cat = `killed by ${runInstaller.signal}`;
2239
+ } else if (runInstaller.status === 137 || runInstaller.status === 124) {
2240
+ cat = `timed out (exit ${runInstaller.status})`;
2241
+ } else if (runInstaller.status === null) {
2242
+ cat = "timed out (status null)";
2243
+ } else {
2244
+ cat = `exited ${runInstaller.status}`;
2225
2245
  }
2246
+ return {
2247
+ name: "upgrade-smoke",
2248
+ status: "FAIL",
2249
+ message: `upgrade-smoke: installer ${cat}. Last stderr line: ${lastStderrLine}`,
2250
+ details: ((runInstaller.stdout || "") + (runInstaller.stderr || "")).split("\n").filter(Boolean).slice(0, 15),
2251
+ durationMs: Date.now() - t0
2252
+ };
2253
+ }
2254
+ const settingsPath = join10(claudeDir, "settings.json");
2255
+ if (!existsSync4(settingsPath)) {
2256
+ return {
2257
+ name: "upgrade-smoke",
2258
+ status: "FAIL",
2259
+ message: "upgrade-smoke: installer did not write ~/.claude/settings.json",
2260
+ details: [
2261
+ `expected: ${settingsPath}`,
2262
+ "The installer ran (exit 0) but the marketplace merge never produced settings.json.",
2263
+ "This is the false-pass shape TR-001 retired \u2014 the gate must observe the write.",
2264
+ ...((runInstaller.stdout || "") + (runInstaller.stderr || "")).split("\n").filter(Boolean).slice(0, 8)
2265
+ ],
2266
+ durationMs: Date.now() - t0
2267
+ };
2268
+ }
2269
+ let settings;
2270
+ try {
2271
+ settings = JSON.parse(readFileSync4(settingsPath, "utf8"));
2272
+ } catch (e) {
2273
+ return {
2274
+ name: "upgrade-smoke",
2275
+ status: "FAIL",
2276
+ message: "upgrade-smoke: settings.json is not valid JSON",
2277
+ details: [e.message],
2278
+ durationMs: Date.now() - t0
2279
+ };
2280
+ }
2281
+ const hasWrongKey = JSON.stringify(settings).includes("ijfw-core");
2282
+ if (hasWrongKey) {
2283
+ return {
2284
+ name: "upgrade-smoke",
2285
+ status: "FAIL",
2286
+ message: 'upgrade-smoke: settings.json still uses deprecated "ijfw-core" key',
2287
+ details: [`Found "ijfw-core" in: ${settingsPath}`],
2288
+ durationMs: Date.now() - t0
2289
+ };
2226
2290
  }
2227
2291
  const marketplaceSrc = join10(installerDir, "src", "marketplace.js");
2228
2292
  if (existsSync4(marketplaceSrc)) {
package/dist/install.js CHANGED
@@ -73,20 +73,44 @@ function writeAtomic(path3, contents, opts = {}) {
73
73
  }
74
74
  }
75
75
  function backup(path3, ts) {
76
+ const res = backupDetailed(path3, ts);
77
+ return res.ok && res.path ? res.path : null;
78
+ }
79
+ function backupDetailed(path3, ts) {
80
+ let st;
76
81
  try {
77
- const st = statSync(path3);
78
- if (!st.isFile()) return null;
79
- } catch {
80
- return null;
82
+ st = statSync(path3);
83
+ } catch (err) {
84
+ if (err && err.code === "ENOENT") return { ok: true, path: null, reason: "absent" };
85
+ return { ok: false, reason: "stat-failed", error: err };
86
+ }
87
+ if (!st.isFile()) {
88
+ return { ok: true, path: null, reason: "not-a-file" };
81
89
  }
82
90
  const dst = `${path3}.bak.${ts}`;
83
91
  try {
84
92
  copyFileSync(path3, dst);
85
93
  printInfo(`backup: ${basename(path3)}.bak.${ts}`);
86
- return dst;
87
- } catch {
94
+ return { ok: true, path: dst };
95
+ } catch (err) {
96
+ return { ok: false, reason: "copy-failed", error: err };
97
+ }
98
+ }
99
+ function requireBackup(path3, ts) {
100
+ if (!ts) return null;
101
+ const res = backupDetailed(path3, ts);
102
+ if (res.ok) return res.path;
103
+ if (process.env.IJFW_FORCE_NO_BACKUP === "1") {
104
+ const why2 = res.error && res.error.message ? res.error.message : res.reason;
105
+ printInfo(`backup: forced past failure for ${basename(path3)} (${why2})`);
88
106
  return null;
89
107
  }
108
+ const why = res.error && res.error.message ? res.error.message : res.reason;
109
+ const err = new Error(
110
+ `Refusing to merge into existing config without backup: ${path3} (${why}). Re-run with IJFW_FORCE_NO_BACKUP=1 to proceed.`
111
+ );
112
+ err.code = "BACKUP_REQUIRED";
113
+ throw err;
90
114
  }
91
115
  function safeChecksum(path3) {
92
116
  try {
@@ -244,7 +268,7 @@ function readJsonOrEmpty(path3) {
244
268
  }
245
269
  function mergeJson(dst, serverJs, ts) {
246
270
  mkdirSync2(dirname3(dst), { recursive: true });
247
- if (ts) backup(dst, ts);
271
+ requireBackup(dst, ts);
248
272
  const doc = readJsonOrEmpty(dst);
249
273
  if (!doc.mcpServers || typeof doc.mcpServers !== "object") doc.mcpServers = {};
250
274
  const isWin = IS_WIN;
@@ -272,7 +296,7 @@ function mergeJson(dst, serverJs, ts) {
272
296
  }
273
297
  function mergeToml(dst, serverJs, ts) {
274
298
  mkdirSync2(dirname3(dst), { recursive: true });
275
- if (ts) backup(dst, ts);
299
+ requireBackup(dst, ts);
276
300
  let text = "";
277
301
  try {
278
302
  text = existsSync3(dst) ? readFileSync2(dst, "utf8") : "";
@@ -560,7 +584,8 @@ import {
560
584
  statSync as statSync2,
561
585
  copyFileSync as copyFileSync2,
562
586
  cpSync,
563
- chmodSync as chmodSync2
587
+ chmodSync as chmodSync2,
588
+ rmSync
564
589
  } from "node:fs";
565
590
  import { join as join4, dirname as dirname4, isAbsolute } from "node:path";
566
591
  import { platform } from "node:os";
@@ -935,10 +960,31 @@ async function installWayland(ctx) {
935
960
  const pluginDst = join4(ctx.home, ".wayland", "plugins", "ijfw");
936
961
  ensureDir(pluginDst);
937
962
  let entries;
963
+ let readdirErr = null;
938
964
  try {
939
965
  entries = readdirSync(pluginSrc);
940
- } catch {
966
+ } catch (err) {
941
967
  entries = [];
968
+ readdirErr = err;
969
+ }
970
+ if (readdirErr) {
971
+ ctx.log.warn(`Wayland plugin tree readdir failed: ${readdirErr.message || readdirErr}`);
972
+ }
973
+ const srcNames = new Set(entries.filter((n) => n !== "__pycache__"));
974
+ let dstEntries = [];
975
+ try {
976
+ dstEntries = readdirSync(pluginDst);
977
+ } catch {
978
+ }
979
+ for (const name of dstEntries) {
980
+ if (name === "__pycache__") continue;
981
+ if (!srcNames.has(name)) {
982
+ try {
983
+ rmSync(join4(pluginDst, name), { recursive: true, force: true });
984
+ } catch (err) {
985
+ ctx.log.warn(`Wayland plugin: could not remove stale ${name}: ${err.message || err}`);
986
+ }
987
+ }
942
988
  }
943
989
  for (const name of entries) {
944
990
  if (name === "__pycache__") continue;
@@ -986,10 +1032,31 @@ async function installHermes(ctx) {
986
1032
  const pluginDst = join4(ctx.home, ".hermes", "plugins", "ijfw");
987
1033
  ensureDir(pluginDst);
988
1034
  let entries;
1035
+ let readdirErr = null;
989
1036
  try {
990
1037
  entries = readdirSync(pluginSrc);
991
- } catch {
1038
+ } catch (err) {
992
1039
  entries = [];
1040
+ readdirErr = err;
1041
+ }
1042
+ if (readdirErr) {
1043
+ ctx.log.warn(`Hermes plugin tree readdir failed: ${readdirErr.message || readdirErr}`);
1044
+ }
1045
+ const srcNames = new Set(entries.filter((n) => n !== "__pycache__"));
1046
+ let dstEntries = [];
1047
+ try {
1048
+ dstEntries = readdirSync(pluginDst);
1049
+ } catch {
1050
+ }
1051
+ for (const name of dstEntries) {
1052
+ if (name === "__pycache__") continue;
1053
+ if (!srcNames.has(name)) {
1054
+ try {
1055
+ rmSync(join4(pluginDst, name), { recursive: true, force: true });
1056
+ } catch (err) {
1057
+ ctx.log.warn(`Hermes plugin: could not remove stale ${name}: ${err.message || err}`);
1058
+ }
1059
+ }
993
1060
  }
994
1061
  for (const name of entries) {
995
1062
  if (name === "__pycache__") continue;
@@ -1181,18 +1248,27 @@ function installOpenclaw(ctx) {
1181
1248
  }
1182
1249
  const dst = path.join(ctx.home, ".openclaw", "openclaw.json");
1183
1250
  const serverJs = ctx.serverJsNative || ctx.serverJs;
1251
+ let cliRegistered = false;
1184
1252
  if (commandExists("openclaw")) {
1185
1253
  try {
1186
1254
  const payload = JSON.stringify({ command: "node", args: [serverJs] });
1187
- execFileSync("openclaw", ["mcp", "set", "ijfw-memory", payload], { stdio: "ignore" });
1188
- printOk(`Registered ijfw-memory via 'openclaw mcp set' (${dst})`);
1189
- return { status: "ok" };
1190
- } catch {
1255
+ execFileSync("openclaw", ["mcp", "set", "ijfw-memory", payload], {
1256
+ stdio: ["ignore", "pipe", "pipe"],
1257
+ encoding: "utf8"
1258
+ });
1259
+ cliRegistered = true;
1260
+ } catch (err) {
1261
+ const msg = err && err.stderr ? String(err.stderr).trim() : err && err.message || String(err);
1262
+ printInfo(`openclaw CLI registration failed (${msg}); falling back to file-write merge.`);
1191
1263
  }
1192
1264
  }
1193
1265
  ensureDir2(path.dirname(dst));
1194
1266
  openclawMerge(dst, serverJs);
1195
- printOk(`Merged MCP into ${dst} (openclaw mcp.servers schema)`);
1267
+ if (cliRegistered) {
1268
+ printOk(`Registered ijfw-memory via 'openclaw mcp set' AND file-write merge (${dst})`);
1269
+ } else {
1270
+ printOk(`Merged MCP into ${dst} (openclaw mcp.servers schema)`);
1271
+ }
1196
1272
  return { status: "ok" };
1197
1273
  }
1198
1274
  function installAider(ctx) {
@@ -1294,6 +1370,14 @@ function linkPlugin({ repoRoot, ijfwHome, ts }) {
1294
1370
  } catch {
1295
1371
  }
1296
1372
  } else if (st.isDirectory()) {
1373
+ try {
1374
+ fs2.rmSync(pluginDst, { recursive: true, force: true });
1375
+ } catch (err) {
1376
+ process.stderr.write(
1377
+ `[ijfw] linkPlugin: could not clear ${pluginDst} for mirror (${err && err.message ? err.message : err}); falling back to merge.
1378
+ `
1379
+ );
1380
+ }
1297
1381
  fs2.cpSync(pluginSrc, pluginDst, { recursive: true });
1298
1382
  printOk(`Plugin tree mirrored to ${pluginDst}`);
1299
1383
  return;
@@ -1369,12 +1453,20 @@ function seedState({ ijfwHome, repoRoot, nodeBin: _nodeBin }) {
1369
1453
  }
1370
1454
  } catch {
1371
1455
  }
1372
- let installedVer = "0.0.0";
1456
+ const pkgPath = path2.join(repoRoot, "installer", "package.json");
1457
+ let installedVer;
1373
1458
  try {
1374
- const pkgPath = path2.join(repoRoot, "installer", "package.json");
1375
1459
  const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
1376
- installedVer = pkg.version || "0.0.0";
1377
- } catch {
1460
+ installedVer = pkg.version;
1461
+ } catch (err) {
1462
+ throw new Error(
1463
+ `Preflight: installer/package.json missing or unreadable at ${pkgPath} (${err && err.message ? err.message : err}). Repo tree incomplete; rerun bootstrap from a clean clone.`
1464
+ );
1465
+ }
1466
+ if (!installedVer || typeof installedVer !== "string") {
1467
+ throw new Error(
1468
+ `Preflight: installer/package.json at ${pkgPath} has no usable "version" field. Rerun bootstrap from a clean clone.`
1469
+ );
1378
1470
  }
1379
1471
  const nowTs = Math.floor(Date.now() / 1e3);
1380
1472
  const state = {
@@ -1484,7 +1576,14 @@ function patchPluginMcpJson({ ijfwHome, repoRoot, nodeBin, serverJs }) {
1484
1576
  d.mcpServers["ijfw-memory"].command = nodeBin;
1485
1577
  d.mcpServers["ijfw-memory"].args = [serverJs];
1486
1578
  const envSep = process.platform === "win32" ? ";" : ":";
1487
- const commonPaths = process.platform === "win32" ? [nodeDir, "C:\\Windows\\System32"] : [nodeDir, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
1579
+ let commonPaths;
1580
+ if (process.platform === "win32") {
1581
+ commonPaths = [nodeDir, "C:\\Windows\\System32"];
1582
+ } else if (process.platform === "darwin") {
1583
+ commonPaths = [nodeDir, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
1584
+ } else {
1585
+ commonPaths = [nodeDir, "/usr/local/bin", "/usr/bin", "/bin"];
1586
+ }
1488
1587
  const dedup = [...new Set(commonPaths.filter((x) => x && fs2.existsSync(x)))];
1489
1588
  d.mcpServers["ijfw-memory"].env = { PATH: dedup.join(envSep) };
1490
1589
  try {
@@ -1857,7 +1956,7 @@ var init_install_flow = __esm({
1857
1956
 
1858
1957
  // src/install.js
1859
1958
  import { spawnSync as spawnSync2 } from "node:child_process";
1860
- import { existsSync as existsSync5, rmSync, mkdirSync as mkdirSync4, realpathSync as realpathSync2, renameSync as renameSync3 } from "node:fs";
1959
+ import { existsSync as existsSync5, rmSync as rmSync2, mkdirSync as mkdirSync4, realpathSync as realpathSync2, renameSync as renameSync3, readdirSync as readdirSync2, cpSync as cpSync2 } from "node:fs";
1861
1960
  import { resolve as resolve4, join as join5, dirname as dirname5 } from "node:path";
1862
1961
  import { homedir as homedir3, platform as platform2 } from "node:os";
1863
1962
  import { fileURLToPath as fileURLToPath2 } from "node:url";
@@ -2017,7 +2116,7 @@ function triggerColdScan(projectRoot, options = {}) {
2017
2116
  }
2018
2117
 
2019
2118
  // src/install.js
2020
- var DEFAULT_REPO = "https://gitlab.com/therealseandonahoe/ijfw.git";
2119
+ var DEFAULT_REPO = "https://github.com/FerroxLabs/ijfw.git";
2021
2120
  var DEFAULT_BRANCH = "main";
2022
2121
  function parseArgs(argv) {
2023
2122
  const out = { yes: false, dir: null, noMarketplace: false, branch: DEFAULT_BRANCH, branchExplicit: false, purge: false };
@@ -2037,7 +2136,11 @@ function parseArgs(argv) {
2037
2136
  }
2038
2137
  return out;
2039
2138
  }
2139
+ function skipNetwork() {
2140
+ return process.env.IJFW_SKIP_NETWORK === "1";
2141
+ }
2040
2142
  function latestTagFromGithub() {
2143
+ if (skipNetwork()) return null;
2041
2144
  try {
2042
2145
  const res = spawnSync2("git", ["ls-remote", "--tags", "--refs", "--sort=-v:refname", DEFAULT_REPO], {
2043
2146
  encoding: "utf8",
@@ -2051,7 +2154,7 @@ function latestTagFromGithub() {
2051
2154
  return null;
2052
2155
  }
2053
2156
  }
2054
- function resolveBranchOrTag({ branch, branchExplicit, _tagLookup } = {}) {
2157
+ function resolveBranchOrTag({ branch, branchExplicit, _tagLookup, _logger } = {}) {
2055
2158
  if (branchExplicit) return branch;
2056
2159
  const lookup = _tagLookup || latestTagFromGithub;
2057
2160
  let tag = null;
@@ -2060,7 +2163,15 @@ function resolveBranchOrTag({ branch, branchExplicit, _tagLookup } = {}) {
2060
2163
  } catch {
2061
2164
  tag = null;
2062
2165
  }
2063
- return tag || branch || DEFAULT_BRANCH;
2166
+ if (!tag) {
2167
+ const log = _logger || console.warn;
2168
+ const eff = branch || DEFAULT_BRANCH;
2169
+ log(
2170
+ ` note: could not resolve latest tag from upstream (network or rate-limit?). Using branch "${eff}" instead. Pin a specific version with --branch vX.Y.Z if needed.`
2171
+ );
2172
+ return eff;
2173
+ }
2174
+ return tag;
2064
2175
  }
2065
2176
  function printHelp() {
2066
2177
  console.log(`ijfw-install -- IJFW installer
@@ -2128,6 +2239,14 @@ function runCheck(cmd, args, opts) {
2128
2239
  return { status: r.status, stdout: r.stdout || "", stderr: r.stderr || "", spawnError: r.error?.code, signal: r.signal };
2129
2240
  }
2130
2241
  function cloneOrPull(dir, branch) {
2242
+ if (skipNetwork()) {
2243
+ if (existsSync5(dir)) {
2244
+ return "skipped-network";
2245
+ }
2246
+ throw new Error(
2247
+ `IJFW_SKIP_NETWORK=1 set but cloneOrPull needs network: target directory ${dir} does not exist. Pre-seed the directory before setting IJFW_SKIP_NETWORK, or unset the env var.`
2248
+ );
2249
+ }
2131
2250
  if (!existsSync5(dir)) {
2132
2251
  mkdirSync4(dir, { recursive: true });
2133
2252
  const r = spawnSync2("git", ["clone", "--depth", "1", "--branch", branch, DEFAULT_REPO, dir], { stdio: "inherit" });
@@ -2143,7 +2262,11 @@ function cloneOrPull(dir, branch) {
2143
2262
  if (remoteStatus === 0) {
2144
2263
  const STALE_PATTERNS = [
2145
2264
  /^https:\/\/github\.com\/seandonahoe\/ijfw(\.git)?\/?$/i,
2146
- /^https:\/\/github\.com\/therealseandonahoe\/ijfw(\.git)?\/?$/i
2265
+ /^https:\/\/github\.com\/therealseandonahoe\/ijfw(\.git)?\/?$/i,
2266
+ // V155 rebrand: GitLab was the canonical source through v1.5.4;
2267
+ // users who installed from gitlab.com need their origin migrated
2268
+ // forward to FerroxLabs/ijfw on GitHub on next `ijfw-install`.
2269
+ /^https:\/\/gitlab\.com\/therealseandonahoe\/ijfw(\.git)?\/?$/i
2147
2270
  ];
2148
2271
  const currentOrigin = (stdout || "").trim();
2149
2272
  if (STALE_PATTERNS.some((re) => re.test(currentOrigin))) {
@@ -2161,23 +2284,63 @@ function cloneOrPull(dir, branch) {
2161
2284
  return "updated";
2162
2285
  }
2163
2286
  }
2287
+ const RESTORE_ALLOWLIST = [
2288
+ "memory",
2289
+ "sessions",
2290
+ "install.log",
2291
+ ".session-counter",
2292
+ // v1.5.x additions:
2293
+ "ijfw",
2294
+ // visible brain layer (wiki + facts)
2295
+ "state",
2296
+ // state.json, deploy-failures.jsonl, .dream-state-v2.json
2297
+ "cache",
2298
+ // npm-view-cache and friends
2299
+ "logs",
2300
+ // post-tool-use logs, jsonl observations
2301
+ "run",
2302
+ // runtime lock files / pid markers
2303
+ ".ijfw"
2304
+ // internal — recall counter, indexes, layout version
2305
+ ];
2164
2306
  const backupDir = dir + ".bak." + Date.now();
2165
2307
  renameSync3(dir, backupDir);
2166
2308
  try {
2167
2309
  const r = spawnSync2("git", ["clone", "--depth", "1", "--branch", branch, DEFAULT_REPO, dir], { stdio: "inherit" });
2168
2310
  if (r.status !== 0) throw new Error(`IJFW repo fetch did not complete (exit ${r.status}) -- check network access and retry.`);
2169
- for (const item of ["memory", "sessions", "install.log", ".session-counter"]) {
2311
+ let restoredCount = 0;
2312
+ for (const item of RESTORE_ALLOWLIST) {
2170
2313
  const src = join5(backupDir, item);
2171
2314
  if (existsSync5(src)) {
2172
2315
  const dst = join5(dir, item);
2173
- if (existsSync5(dst)) rmSync(dst, { recursive: true, force: true });
2174
- renameSync3(src, dst);
2316
+ if (existsSync5(dst)) rmSync2(dst, { recursive: true, force: true });
2317
+ try {
2318
+ cpSync2(src, dst, { recursive: true, dereference: false });
2319
+ rmSync2(src, { recursive: true, force: true });
2320
+ restoredCount++;
2321
+ } catch (cpErr) {
2322
+ const msg = cpErr && cpErr.message ? cpErr.message : String(cpErr);
2323
+ throw new Error(
2324
+ `IJFW restore: cpSync failed for "${item}" (${msg}). Your data is still intact under: ${backupDir}. Move it back into ${dir} manually after diagnosing the copy failure.`
2325
+ );
2326
+ }
2175
2327
  }
2176
2328
  }
2177
- rmSync(backupDir, { recursive: true, force: true });
2329
+ let backupResidual = [];
2330
+ try {
2331
+ backupResidual = readdirSync2(backupDir);
2332
+ } catch {
2333
+ }
2334
+ if (backupResidual.length === 0) {
2335
+ rmSync2(backupDir, { recursive: true, force: true });
2336
+ } else {
2337
+ console.warn(
2338
+ ` [!] restored ${restoredCount} known dirs; backup retained at ${backupDir} (contains: ${backupResidual.slice(0, 8).join(", ")}${backupResidual.length > 8 ? ", ..." : ""}). Remove manually after verifying.`
2339
+ );
2340
+ }
2178
2341
  return "updated";
2179
2342
  } catch (err) {
2180
- if (existsSync5(dir)) rmSync(dir, { recursive: true, force: true });
2343
+ if (existsSync5(dir)) rmSync2(dir, { recursive: true, force: true });
2181
2344
  renameSync3(backupDir, dir);
2182
2345
  throw err;
2183
2346
  }
@@ -2208,8 +2371,13 @@ async function main() {
2208
2371
  const sigint = () => {
2209
2372
  if (createdThisRun && existsSync5(target)) {
2210
2373
  try {
2211
- rmSync(target, { recursive: true, force: true });
2212
- } catch {
2374
+ rmSync2(target, { recursive: true, force: true });
2375
+ } catch (err) {
2376
+ const msg = err && err.message ? err.message : String(err);
2377
+ console.warn(
2378
+ `
2379
+ [!] partial install at ${target} could not be cleaned (${msg}) \u2014 run \`rm -rf "${target}"\` (or Remove-Item -Recurse -Force on Windows) before retrying.`
2380
+ );
2213
2381
  }
2214
2382
  }
2215
2383
  process.exit(130);
package/docs/GUIDE.md CHANGED
@@ -502,7 +502,7 @@ cat ~/.ijfw/dashboard.port
502
502
 
503
503
  ### Still stuck
504
504
 
505
- Every install writes a log to `~/.ijfw/install.log`. Every session writes observations to `~/.ijfw/observations.jsonl`. Open an issue at [gitlab.com/therealseandonahoe/ijfw/-/issues](https://gitlab.com/therealseandonahoe/ijfw/-/issues) with both files redacted and attached.
505
+ Every install writes a log to `~/.ijfw/install.log`. Every session writes observations to `~/.ijfw/observations.jsonl`. Open an issue at [github.com/FerroxLabs/ijfw/issues](https://github.com/FerroxLabs/ijfw/issues) with both files redacted and attached.
506
506
 
507
507
  ---
508
508
 
@@ -545,7 +545,7 @@ Yes. `.ijfw/team/` is git-committed by default. Decisions, patterns, and stack c
545
545
  </p>
546
546
 
547
547
  <p align="center">
548
- <a href="https://gitlab.com/therealseandonahoe/ijfw">gitlab.com/therealseandonahoe/ijfw</a>
548
+ <a href="https://github.com/FerroxLabs/ijfw">github.com/FerroxLabs/ijfw</a>
549
549
  &nbsp;|&nbsp;
550
550
  <a href="https://www.npmjs.com/package/@ijfw/install">npm</a>
551
551
  &nbsp;|&nbsp;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ijfw/install",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "One-command installer for IJFW -- the AI efficiency layer. One install, every AI coding agent, zero config.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,13 +51,19 @@
51
51
  ],
52
52
  "license": "MIT",
53
53
  "author": "Sean Donahoe",
54
- "homepage": "https://gitlab.com/therealseandonahoe/ijfw",
54
+ "contributors": [
55
+ {
56
+ "name": "Ferrox Labs",
57
+ "url": "https://ferroxlabs.com"
58
+ }
59
+ ],
60
+ "homepage": "https://github.com/FerroxLabs/ijfw",
55
61
  "bugs": {
56
- "url": "https://gitlab.com/therealseandonahoe/ijfw/-/issues"
62
+ "url": "https://github.com/FerroxLabs/ijfw/issues"
57
63
  },
58
64
  "repository": {
59
65
  "type": "git",
60
- "url": "git+https://gitlab.com/therealseandonahoe/ijfw.git"
66
+ "url": "git+https://github.com/FerroxLabs/ijfw.git"
61
67
  },
62
68
  "publishConfig": {
63
69
  "access": "public"
@@ -10,11 +10,16 @@
10
10
  'use strict';
11
11
 
12
12
  const { spawnSync } = require('child_process');
13
+ const os = require('os');
14
+
15
+ // V155-066: use os.tmpdir() instead of hardcoded /tmp so the hook works on
16
+ // Windows (where /tmp does not exist) and on hosts whose /tmp is read-only.
17
+ const packDest = os.tmpdir();
13
18
 
14
19
  // Phase 1: pre-fetch (network) — pull package into npm cache.
15
20
  const fetchResult = spawnSync(
16
21
  'npm',
17
- ['pack', '@ijfw/install@{{VERSION}}', '--silent', '--prefer-offline', '--pack-destination', '/tmp'],
22
+ ['pack', '@ijfw/install@{{VERSION}}', '--silent', '--prefer-offline', '--pack-destination', packDest],
18
23
  { stdio: 'pipe', timeout: 60_000 },
19
24
  );
20
25
  if (fetchResult.status !== 0) {
@@ -29,7 +29,7 @@ import {
29
29
  writeFileSync,
30
30
  } from 'node:fs';
31
31
  import { tmpdir as osTmpdir } from 'node:os';
32
- import { dirname, join, relative, resolve } from 'node:path';
32
+ import { dirname, join, resolve } from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
34
 
35
35
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -103,25 +103,72 @@ const WINDOWS_SYSTEM_DIRS = [
103
103
  * @param {string} absPath Already-resolved absolute path.
104
104
  * @returns {boolean}
105
105
  */
106
+ /**
107
+ * canonicalizeAtomic — resolve a path through realpath even when the leaf
108
+ * does not yet exist. Walks up to the deepest existing ancestor, realpaths
109
+ * that, then reattaches the remaining suffix. Handles the macOS
110
+ * /var ↔ /private/var symlink AND the "not-yet-created subdir" case in one
111
+ * pass. Mirrors the same helper in path-guard.js. (V155-036 / L1-02 recur)
112
+ */
113
+ function canonicalizeAtomic(p) {
114
+ try { return realpathSync(p); } catch { /* fall through */ }
115
+ const parts = p.split(/[/\\]/);
116
+ const sep = p.includes('\\') && !p.includes('/') ? '\\' : '/';
117
+ let suffix = [];
118
+ while (parts.length > 0) {
119
+ suffix.unshift(parts.pop());
120
+ const head = parts.join(sep) || sep;
121
+ try {
122
+ const real = realpathSync(head);
123
+ return suffix.length > 0 ? `${real}${sep}${suffix.join(sep)}` : real;
124
+ } catch { /* keep walking up */ }
125
+ }
126
+ return p; // give up; return the input
127
+ }
128
+
106
129
  function isSystemPath(absPath) {
107
130
  // Reject bare filesystem roots: '/', 'C:\', 'D:\', etc.
108
131
  if (/^[A-Za-z]:\\?$/.test(absPath) || absPath === '/') return true;
109
132
 
133
+ // V155-036: pre-canonicalize BOTH sides via the deepest-existing-ancestor
134
+ // walk so the macOS /var → /private/var symlink doesn't slip through when
135
+ // the requested output dir doesn't exist yet (the prior realpathSync on
136
+ // absPath silently kept the unresolved form, which then matched the
137
+ // /private prefix in the blocklist).
138
+ const tmp = osTmpdir();
139
+ const tmpReal = canonicalizeAtomic(tmp);
140
+ const absReal = canonicalizeAtomic(absPath);
141
+
110
142
  // OS temp-dir whitelist — overrides the system-prefix blocklist.
111
- // Resolve both sides so macOS /var → /private/var symlinks compare cleanly.
112
- try {
113
- const tmp = osTmpdir();
114
- const tmpReal = realpathSync(tmp);
115
- const absReal = (() => { try { return realpathSync(absPath); } catch { return absPath; } })();
116
- if (absReal === tmpReal || absReal.startsWith(tmpReal + '/') || absReal.startsWith(tmpReal + '\\')
117
- || absPath === tmp || absPath.startsWith(tmp + '/') || absPath.startsWith(tmp + '\\')) {
118
- return false;
119
- }
120
- } catch { /* fall through to blocklist */ }
143
+ if (
144
+ absReal === tmpReal ||
145
+ absReal.startsWith(tmpReal + '/') ||
146
+ absReal.startsWith(tmpReal + '\\') ||
147
+ absPath === tmp ||
148
+ absPath.startsWith(tmp + '/') ||
149
+ absPath.startsWith(tmp + '\\')
150
+ ) {
151
+ return false;
152
+ }
121
153
 
122
- const lc = absPath.toLowerCase();
123
- const allBlocked = [...POSIX_SYSTEM_DIRS, ...WINDOWS_SYSTEM_DIRS];
124
- for (const blocked of allBlocked) {
154
+ // V155-059: gate the system-prefix lists by platform so cross-platform
155
+ // false positives go away. macOS users packing under /Library/MyProjects
156
+ // shouldn't trip the Windows blocklist, and Windows users shouldn't trip
157
+ // the POSIX blocklist. Prefer runtime-derived Windows prefixes when set.
158
+ const isWin = process.platform === 'win32';
159
+ const dynamicWinDirs = isWin
160
+ ? [
161
+ process.env.windir,
162
+ process.env.ProgramFiles,
163
+ process.env['ProgramFiles(x86)'],
164
+ ].filter((s) => typeof s === 'string' && s.length > 0)
165
+ : [];
166
+ const blockedForPlatform = isWin
167
+ ? [...WINDOWS_SYSTEM_DIRS, ...dynamicWinDirs]
168
+ : [...POSIX_SYSTEM_DIRS];
169
+
170
+ const lc = absReal.toLowerCase();
171
+ for (const blocked of blockedForPlatform) {
125
172
  const bl = blocked.toLowerCase();
126
173
  if (lc === bl || lc.startsWith(bl + '/') || lc.startsWith(bl + '\\')) {
127
174
  return true;
package/src/install.ps1 CHANGED
@@ -94,10 +94,25 @@ function Invoke-CloneOrPull($target, $branch) {
94
94
  if ($LASTEXITCODE -eq 0) {
95
95
  # Self-heal stale origin URLs across host migrations (1.2.9 parity with install.js).
96
96
  # Without this, Windows users on the pre-GitLab origin still 404 on every upgrade.
97
+ # V155-012: only rewrite ORIGINS THAT MATCH KNOWN STALE PATTERNS. Previously
98
+ # this clobbered SSH remotes, forks, and any user-customized origin — anyone
99
+ # working on the IJFW source itself ended up silently retargeted to upstream.
100
+ # Port the install.js STALE_PATTERNS allowlist verbatim (case-insensitive).
97
101
  $currentOrigin = ($currentOriginRaw | Out-String).Trim()
98
- if ($currentOrigin -and $currentOrigin -ne $DEFAULT_REPO) {
102
+ $stalePatterns = @(
103
+ '^https://github\.com/seandonahoe/ijfw(\.git)?/?$',
104
+ '^https://github\.com/therealseandonahoe/ijfw(\.git)?/?$'
105
+ )
106
+ $isStale = $false
107
+ foreach ($pat in $stalePatterns) {
108
+ if ($currentOrigin -imatch $pat) { $isStale = $true; break }
109
+ }
110
+ if ($currentOrigin -and $isStale) {
99
111
  Write-Host " origin migration: $currentOrigin -> $DEFAULT_REPO"
100
112
  & git -C $target remote set-url origin $DEFAULT_REPO
113
+ if ($LASTEXITCODE -ne 0) {
114
+ Write-Warning " origin migration failed -- could not repoint $currentOrigin to $DEFAULT_REPO"
115
+ }
101
116
  }
102
117
  # fetch + hard checkout avoids ff-only failures from local divergence.
103
118
  & git -C $target fetch --depth 1 origin $branch
@@ -118,7 +133,13 @@ function Invoke-CloneOrPull($target, $branch) {
118
133
  try {
119
134
  & git clone --depth 1 --branch $branch $DEFAULT_REPO $target
120
135
  if ($LASTEXITCODE -ne 0) { throw "Could not reach the IJFW repo (exit $LASTEXITCODE). Check your network connection and retry." }
121
- foreach ($item in @('memory', 'sessions', 'install.log', '.session-counter')) {
136
+ # V155-013: expanded restore allowlist + retain backup if residual content remains
137
+ # so the operator can recover anything the allowlist doesn't know about.
138
+ $restoreAllowlist = @(
139
+ 'memory', 'sessions', 'install.log', '.session-counter',
140
+ 'ijfw', 'state', 'cache', 'logs', 'run', '.ijfw'
141
+ )
142
+ foreach ($item in $restoreAllowlist) {
122
143
  $src = Join-Path $backupDir $item
123
144
  if (Test-Path $src) {
124
145
  $dst = Join-Path $target $item
@@ -126,7 +147,13 @@ function Invoke-CloneOrPull($target, $branch) {
126
147
  Move-Item -LiteralPath $src -Destination $dst -Force
127
148
  }
128
149
  }
129
- Remove-Item -Recurse -Force -LiteralPath $backupDir
150
+ $residual = Get-ChildItem -LiteralPath $backupDir -Force -ErrorAction SilentlyContinue
151
+ if (-not $residual -or $residual.Count -eq 0) {
152
+ Remove-Item -Recurse -Force -LiteralPath $backupDir -ErrorAction SilentlyContinue
153
+ } else {
154
+ $sample = ($residual | Select-Object -First 8 | ForEach-Object { $_.Name }) -join ', '
155
+ Write-Warning " restored known dirs; backup retained at $backupDir (contains: $sample). Remove manually after verifying."
156
+ }
130
157
  return "updated"
131
158
  } catch {
132
159
  if (Test-Path $target) { Remove-Item -Recurse -Force -LiteralPath $target }