@ijfw/install 1.5.4 → 1.5.6
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 +33 -65
- package/dist/hub-index-snippet.json +7 -6
- package/dist/ijfw.js +88 -24
- package/dist/install.js +217 -35
- package/docs/GUIDE.md +2 -2
- package/docs/guide/assets/ferrox-hero.png +0 -0
- package/package.json +10 -4
- package/scripts/hub-extension/install.js.tmpl +6 -1
- package/scripts/pack-hub-extension.js +61 -14
- package/src/install.ps1 +36 -5
package/README.md
CHANGED
|
@@ -1,92 +1,60 @@
|
|
|
1
|
-
# @ijfw/install
|
|
1
|
+
# @ijfw/install — One-command installer for IJFW
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
21
|
+
ijfw install
|
|
11
22
|
```
|
|
12
23
|
|
|
13
|
-
|
|
24
|
+
If no AI agents are detected, install Claude Code or Codex first, then re-run.
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
14
27
|
|
|
15
|
-
|
|
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` |
|
|
22
|
-
| `--yes` |
|
|
40
|
+
| `--no-marketplace` | enabled | Skip settings.json edits |
|
|
41
|
+
| `--yes` | interactive | Non-interactive run |
|
|
23
42
|
|
|
24
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5
|
-
"description": "One install, every AI coding agent, zero config. Unifies
|
|
4
|
+
"version": "1.5.6",
|
|
5
|
+
"description": "One install, every AI coding agent, zero config. Unifies 16 CLIs under a shared MCP memory layer so context follows you across Claude, Codex, Gemini, Cursor, Windsurf, and 11 more.",
|
|
6
6
|
"author": "Sean Donahoe",
|
|
7
7
|
"icon": "assets/ijfw-logo.svg",
|
|
8
8
|
"dist": {
|
|
9
|
-
"tarball": "extensions/ijfw-1.5.
|
|
10
|
-
"integrity": "sha512-
|
|
11
|
-
"unpackedSize":
|
|
9
|
+
"tarball": "extensions/ijfw-1.5.6.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"
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"cline",
|
|
34
34
|
"kimicode",
|
|
35
35
|
"openclaw",
|
|
36
|
-
"antigravity"
|
|
36
|
+
"antigravity",
|
|
37
|
+
"pi"
|
|
37
38
|
],
|
|
38
39
|
"mcpServers": [
|
|
39
40
|
"ijfw-memory"
|
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
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
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)) {
|
|
@@ -4391,7 +4455,7 @@ async function main() {
|
|
|
4391
4455
|
];
|
|
4392
4456
|
const guidePath = candidates.find((p) => existsSync6(p));
|
|
4393
4457
|
if (!guidePath) {
|
|
4394
|
-
console.error("[ijfw] Guide not found. Run `ijfw install` to fetch the full guide, or visit https://
|
|
4458
|
+
console.error("[ijfw] Guide not found. Run `ijfw install` to fetch the full guide, or visit https://github.com/FerroxLabs/ijfw/blob/main/docs/GUIDE.md");
|
|
4395
4459
|
process.exit(1);
|
|
4396
4460
|
}
|
|
4397
4461
|
if (wantsBrowser) {
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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], {
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -1226,6 +1302,18 @@ function installAntigravity(ctx) {
|
|
|
1226
1302
|
printOk(`Merged MCP into ${ideDst} + ${cliDst} (Antigravity IDE + CLI)`);
|
|
1227
1303
|
return { status: "ok" };
|
|
1228
1304
|
}
|
|
1305
|
+
function installPi(ctx) {
|
|
1306
|
+
if (ctx.ijfwCustomDir) {
|
|
1307
|
+
printInfo("Custom-dir install -- skipping Pi merges.");
|
|
1308
|
+
printOk("Pi: real platform config left untouched.");
|
|
1309
|
+
return { status: "noop" };
|
|
1310
|
+
}
|
|
1311
|
+
const agentsSrc = path.join(ctx.repoRoot, "pi", "AGENTS.md");
|
|
1312
|
+
const agentsDst = path.join(ctx.home, ".pi", "agent", "AGENTS.md");
|
|
1313
|
+
copyIfMissing(agentsSrc, agentsDst);
|
|
1314
|
+
printOk("Pi: rules-only install (~/.pi/agent/AGENTS.md). No MCP -- Pi has no native MCP client (extension bridge required for memory).");
|
|
1315
|
+
return { status: "ok" };
|
|
1316
|
+
}
|
|
1229
1317
|
var init_install_targets_8_14 = __esm({
|
|
1230
1318
|
"src/install-targets-8-14.js"() {
|
|
1231
1319
|
init_install_helpers();
|
|
@@ -1294,6 +1382,14 @@ function linkPlugin({ repoRoot, ijfwHome, ts }) {
|
|
|
1294
1382
|
} catch {
|
|
1295
1383
|
}
|
|
1296
1384
|
} else if (st.isDirectory()) {
|
|
1385
|
+
try {
|
|
1386
|
+
fs2.rmSync(pluginDst, { recursive: true, force: true });
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
process.stderr.write(
|
|
1389
|
+
`[ijfw] linkPlugin: could not clear ${pluginDst} for mirror (${err && err.message ? err.message : err}); falling back to merge.
|
|
1390
|
+
`
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1297
1393
|
fs2.cpSync(pluginSrc, pluginDst, { recursive: true });
|
|
1298
1394
|
printOk(`Plugin tree mirrored to ${pluginDst}`);
|
|
1299
1395
|
return;
|
|
@@ -1369,12 +1465,20 @@ function seedState({ ijfwHome, repoRoot, nodeBin: _nodeBin }) {
|
|
|
1369
1465
|
}
|
|
1370
1466
|
} catch {
|
|
1371
1467
|
}
|
|
1372
|
-
|
|
1468
|
+
const pkgPath = path2.join(repoRoot, "installer", "package.json");
|
|
1469
|
+
let installedVer;
|
|
1373
1470
|
try {
|
|
1374
|
-
const pkgPath = path2.join(repoRoot, "installer", "package.json");
|
|
1375
1471
|
const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
|
|
1376
|
-
installedVer = pkg.version
|
|
1377
|
-
} catch {
|
|
1472
|
+
installedVer = pkg.version;
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
throw new Error(
|
|
1475
|
+
`Preflight: installer/package.json missing or unreadable at ${pkgPath} (${err && err.message ? err.message : err}). Repo tree incomplete; rerun bootstrap from a clean clone.`
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
if (!installedVer || typeof installedVer !== "string") {
|
|
1479
|
+
throw new Error(
|
|
1480
|
+
`Preflight: installer/package.json at ${pkgPath} has no usable "version" field. Rerun bootstrap from a clean clone.`
|
|
1481
|
+
);
|
|
1378
1482
|
}
|
|
1379
1483
|
const nowTs = Math.floor(Date.now() / 1e3);
|
|
1380
1484
|
const state = {
|
|
@@ -1484,7 +1588,14 @@ function patchPluginMcpJson({ ijfwHome, repoRoot, nodeBin, serverJs }) {
|
|
|
1484
1588
|
d.mcpServers["ijfw-memory"].command = nodeBin;
|
|
1485
1589
|
d.mcpServers["ijfw-memory"].args = [serverJs];
|
|
1486
1590
|
const envSep = process.platform === "win32" ? ";" : ":";
|
|
1487
|
-
|
|
1591
|
+
let commonPaths;
|
|
1592
|
+
if (process.platform === "win32") {
|
|
1593
|
+
commonPaths = [nodeDir, "C:\\Windows\\System32"];
|
|
1594
|
+
} else if (process.platform === "darwin") {
|
|
1595
|
+
commonPaths = [nodeDir, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
|
|
1596
|
+
} else {
|
|
1597
|
+
commonPaths = [nodeDir, "/usr/local/bin", "/usr/bin", "/bin"];
|
|
1598
|
+
}
|
|
1488
1599
|
const dedup = [...new Set(commonPaths.filter((x) => x && fs2.existsSync(x)))];
|
|
1489
1600
|
d.mcpServers["ijfw-memory"].env = { PATH: dedup.join(envSep) };
|
|
1490
1601
|
try {
|
|
@@ -1832,7 +1943,8 @@ var init_install_flow = __esm({
|
|
|
1832
1943
|
"kimi",
|
|
1833
1944
|
"openclaw",
|
|
1834
1945
|
"aider",
|
|
1835
|
-
"antigravity"
|
|
1946
|
+
"antigravity",
|
|
1947
|
+
"pi"
|
|
1836
1948
|
];
|
|
1837
1949
|
TARGET_FNS = {
|
|
1838
1950
|
claude: installClaude,
|
|
@@ -1849,7 +1961,8 @@ var init_install_flow = __esm({
|
|
|
1849
1961
|
kimi: installKimi,
|
|
1850
1962
|
openclaw: installOpenclaw,
|
|
1851
1963
|
aider: installAider,
|
|
1852
|
-
antigravity: installAntigravity
|
|
1964
|
+
antigravity: installAntigravity,
|
|
1965
|
+
pi: installPi
|
|
1853
1966
|
};
|
|
1854
1967
|
install_flow_default = { runInstall, CANONICAL_ORDER };
|
|
1855
1968
|
}
|
|
@@ -1857,7 +1970,7 @@ var init_install_flow = __esm({
|
|
|
1857
1970
|
|
|
1858
1971
|
// src/install.js
|
|
1859
1972
|
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";
|
|
1973
|
+
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
1974
|
import { resolve as resolve4, join as join5, dirname as dirname5 } from "node:path";
|
|
1862
1975
|
import { homedir as homedir3, platform as platform2 } from "node:os";
|
|
1863
1976
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
@@ -2017,7 +2130,7 @@ function triggerColdScan(projectRoot, options = {}) {
|
|
|
2017
2130
|
}
|
|
2018
2131
|
|
|
2019
2132
|
// src/install.js
|
|
2020
|
-
var DEFAULT_REPO = "https://
|
|
2133
|
+
var DEFAULT_REPO = "https://github.com/FerroxLabs/ijfw.git";
|
|
2021
2134
|
var DEFAULT_BRANCH = "main";
|
|
2022
2135
|
function parseArgs(argv) {
|
|
2023
2136
|
const out = { yes: false, dir: null, noMarketplace: false, branch: DEFAULT_BRANCH, branchExplicit: false, purge: false };
|
|
@@ -2037,7 +2150,11 @@ function parseArgs(argv) {
|
|
|
2037
2150
|
}
|
|
2038
2151
|
return out;
|
|
2039
2152
|
}
|
|
2153
|
+
function skipNetwork() {
|
|
2154
|
+
return process.env.IJFW_SKIP_NETWORK === "1";
|
|
2155
|
+
}
|
|
2040
2156
|
function latestTagFromGithub() {
|
|
2157
|
+
if (skipNetwork()) return null;
|
|
2041
2158
|
try {
|
|
2042
2159
|
const res = spawnSync2("git", ["ls-remote", "--tags", "--refs", "--sort=-v:refname", DEFAULT_REPO], {
|
|
2043
2160
|
encoding: "utf8",
|
|
@@ -2051,7 +2168,7 @@ function latestTagFromGithub() {
|
|
|
2051
2168
|
return null;
|
|
2052
2169
|
}
|
|
2053
2170
|
}
|
|
2054
|
-
function resolveBranchOrTag({ branch, branchExplicit, _tagLookup } = {}) {
|
|
2171
|
+
function resolveBranchOrTag({ branch, branchExplicit, _tagLookup, _logger } = {}) {
|
|
2055
2172
|
if (branchExplicit) return branch;
|
|
2056
2173
|
const lookup = _tagLookup || latestTagFromGithub;
|
|
2057
2174
|
let tag = null;
|
|
@@ -2060,7 +2177,15 @@ function resolveBranchOrTag({ branch, branchExplicit, _tagLookup } = {}) {
|
|
|
2060
2177
|
} catch {
|
|
2061
2178
|
tag = null;
|
|
2062
2179
|
}
|
|
2063
|
-
|
|
2180
|
+
if (!tag) {
|
|
2181
|
+
const log = _logger || console.warn;
|
|
2182
|
+
const eff = branch || DEFAULT_BRANCH;
|
|
2183
|
+
log(
|
|
2184
|
+
` 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.`
|
|
2185
|
+
);
|
|
2186
|
+
return eff;
|
|
2187
|
+
}
|
|
2188
|
+
return tag;
|
|
2064
2189
|
}
|
|
2065
2190
|
function printHelp() {
|
|
2066
2191
|
console.log(`ijfw-install -- IJFW installer
|
|
@@ -2128,6 +2253,14 @@ function runCheck(cmd, args, opts) {
|
|
|
2128
2253
|
return { status: r.status, stdout: r.stdout || "", stderr: r.stderr || "", spawnError: r.error?.code, signal: r.signal };
|
|
2129
2254
|
}
|
|
2130
2255
|
function cloneOrPull(dir, branch) {
|
|
2256
|
+
if (skipNetwork()) {
|
|
2257
|
+
if (existsSync5(dir)) {
|
|
2258
|
+
return "skipped-network";
|
|
2259
|
+
}
|
|
2260
|
+
throw new Error(
|
|
2261
|
+
`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.`
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2131
2264
|
if (!existsSync5(dir)) {
|
|
2132
2265
|
mkdirSync4(dir, { recursive: true });
|
|
2133
2266
|
const r = spawnSync2("git", ["clone", "--depth", "1", "--branch", branch, DEFAULT_REPO, dir], { stdio: "inherit" });
|
|
@@ -2143,7 +2276,11 @@ function cloneOrPull(dir, branch) {
|
|
|
2143
2276
|
if (remoteStatus === 0) {
|
|
2144
2277
|
const STALE_PATTERNS = [
|
|
2145
2278
|
/^https:\/\/github\.com\/seandonahoe\/ijfw(\.git)?\/?$/i,
|
|
2146
|
-
/^https:\/\/github\.com\/therealseandonahoe\/ijfw(\.git)?\/?$/i
|
|
2279
|
+
/^https:\/\/github\.com\/therealseandonahoe\/ijfw(\.git)?\/?$/i,
|
|
2280
|
+
// V155 rebrand: GitLab was the canonical source through v1.5.4;
|
|
2281
|
+
// users who installed from gitlab.com need their origin migrated
|
|
2282
|
+
// forward to FerroxLabs/ijfw on GitHub on next `ijfw-install`.
|
|
2283
|
+
/^https:\/\/gitlab\.com\/therealseandonahoe\/ijfw(\.git)?\/?$/i
|
|
2147
2284
|
];
|
|
2148
2285
|
const currentOrigin = (stdout || "").trim();
|
|
2149
2286
|
if (STALE_PATTERNS.some((re) => re.test(currentOrigin))) {
|
|
@@ -2161,23 +2298,63 @@ function cloneOrPull(dir, branch) {
|
|
|
2161
2298
|
return "updated";
|
|
2162
2299
|
}
|
|
2163
2300
|
}
|
|
2301
|
+
const RESTORE_ALLOWLIST = [
|
|
2302
|
+
"memory",
|
|
2303
|
+
"sessions",
|
|
2304
|
+
"install.log",
|
|
2305
|
+
".session-counter",
|
|
2306
|
+
// v1.5.x additions:
|
|
2307
|
+
"ijfw",
|
|
2308
|
+
// visible brain layer (wiki + facts)
|
|
2309
|
+
"state",
|
|
2310
|
+
// state.json, deploy-failures.jsonl, .dream-state-v2.json
|
|
2311
|
+
"cache",
|
|
2312
|
+
// npm-view-cache and friends
|
|
2313
|
+
"logs",
|
|
2314
|
+
// post-tool-use logs, jsonl observations
|
|
2315
|
+
"run",
|
|
2316
|
+
// runtime lock files / pid markers
|
|
2317
|
+
".ijfw"
|
|
2318
|
+
// internal — recall counter, indexes, layout version
|
|
2319
|
+
];
|
|
2164
2320
|
const backupDir = dir + ".bak." + Date.now();
|
|
2165
2321
|
renameSync3(dir, backupDir);
|
|
2166
2322
|
try {
|
|
2167
2323
|
const r = spawnSync2("git", ["clone", "--depth", "1", "--branch", branch, DEFAULT_REPO, dir], { stdio: "inherit" });
|
|
2168
2324
|
if (r.status !== 0) throw new Error(`IJFW repo fetch did not complete (exit ${r.status}) -- check network access and retry.`);
|
|
2169
|
-
|
|
2325
|
+
let restoredCount = 0;
|
|
2326
|
+
for (const item of RESTORE_ALLOWLIST) {
|
|
2170
2327
|
const src = join5(backupDir, item);
|
|
2171
2328
|
if (existsSync5(src)) {
|
|
2172
2329
|
const dst = join5(dir, item);
|
|
2173
|
-
if (existsSync5(dst))
|
|
2174
|
-
|
|
2330
|
+
if (existsSync5(dst)) rmSync2(dst, { recursive: true, force: true });
|
|
2331
|
+
try {
|
|
2332
|
+
cpSync2(src, dst, { recursive: true, dereference: false });
|
|
2333
|
+
rmSync2(src, { recursive: true, force: true });
|
|
2334
|
+
restoredCount++;
|
|
2335
|
+
} catch (cpErr) {
|
|
2336
|
+
const msg = cpErr && cpErr.message ? cpErr.message : String(cpErr);
|
|
2337
|
+
throw new Error(
|
|
2338
|
+
`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.`
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2175
2341
|
}
|
|
2176
2342
|
}
|
|
2177
|
-
|
|
2343
|
+
let backupResidual = [];
|
|
2344
|
+
try {
|
|
2345
|
+
backupResidual = readdirSync2(backupDir);
|
|
2346
|
+
} catch {
|
|
2347
|
+
}
|
|
2348
|
+
if (backupResidual.length === 0) {
|
|
2349
|
+
rmSync2(backupDir, { recursive: true, force: true });
|
|
2350
|
+
} else {
|
|
2351
|
+
console.warn(
|
|
2352
|
+
` [!] restored ${restoredCount} known dirs; backup retained at ${backupDir} (contains: ${backupResidual.slice(0, 8).join(", ")}${backupResidual.length > 8 ? ", ..." : ""}). Remove manually after verifying.`
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2178
2355
|
return "updated";
|
|
2179
2356
|
} catch (err) {
|
|
2180
|
-
if (existsSync5(dir))
|
|
2357
|
+
if (existsSync5(dir)) rmSync2(dir, { recursive: true, force: true });
|
|
2181
2358
|
renameSync3(backupDir, dir);
|
|
2182
2359
|
throw err;
|
|
2183
2360
|
}
|
|
@@ -2208,8 +2385,13 @@ async function main() {
|
|
|
2208
2385
|
const sigint = () => {
|
|
2209
2386
|
if (createdThisRun && existsSync5(target)) {
|
|
2210
2387
|
try {
|
|
2211
|
-
|
|
2212
|
-
} catch {
|
|
2388
|
+
rmSync2(target, { recursive: true, force: true });
|
|
2389
|
+
} catch (err) {
|
|
2390
|
+
const msg = err && err.message ? err.message : String(err);
|
|
2391
|
+
console.warn(
|
|
2392
|
+
`
|
|
2393
|
+
[!] partial install at ${target} could not be cleaned (${msg}) \u2014 run \`rm -rf "${target}"\` (or Remove-Item -Recurse -Force on Windows) before retrying.`
|
|
2394
|
+
);
|
|
2213
2395
|
}
|
|
2214
2396
|
}
|
|
2215
2397
|
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 [
|
|
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://
|
|
548
|
+
<a href="https://github.com/FerroxLabs/ijfw">github.com/FerroxLabs/ijfw</a>
|
|
549
549
|
|
|
|
550
550
|
<a href="https://www.npmjs.com/package/@ijfw/install">npm</a>
|
|
551
551
|
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ijfw/install",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.6",
|
|
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
|
-
"
|
|
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://
|
|
62
|
+
"url": "https://github.com/FerroxLabs/ijfw/issues"
|
|
57
63
|
},
|
|
58
64
|
"repository": {
|
|
59
65
|
"type": "git",
|
|
60
|
-
"url": "git+https://
|
|
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',
|
|
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,
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# -> merge marketplace into %USERPROFILE%\.claude\settings.json -> summary.
|
|
7
7
|
#
|
|
8
8
|
# Usage:
|
|
9
|
-
# Invoke-Expression (iwr https://
|
|
9
|
+
# Invoke-Expression (iwr https://raw.githubusercontent.com/FerroxLabs/ijfw/main/installer/src/install.ps1).Content
|
|
10
10
|
# or:
|
|
11
11
|
# .\install.ps1 -Dir C:\Users\me\.ijfw -Branch main
|
|
12
12
|
|
|
@@ -19,7 +19,7 @@ param(
|
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
$ErrorActionPreference = "Stop"
|
|
22
|
-
$DEFAULT_REPO = "https://
|
|
22
|
+
$DEFAULT_REPO = "https://github.com/FerroxLabs/ijfw.git"
|
|
23
23
|
|
|
24
24
|
function Write-Ok($msg) { Write-Host " [ok] $msg" -ForegroundColor Green }
|
|
25
25
|
function Write-Info($msg) { Write-Host " ... $msg" -ForegroundColor Gray }
|
|
@@ -94,10 +94,29 @@ 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
|
-
|
|
102
|
+
$stalePatterns = @(
|
|
103
|
+
'^https://github\.com/seandonahoe/ijfw(\.git)?/?$',
|
|
104
|
+
'^https://github\.com/therealseandonahoe/ijfw(\.git)?/?$',
|
|
105
|
+
# V155 rebrand: GitLab was canonical through v1.5.4; Windows users who
|
|
106
|
+
# installed from gitlab.com need their origin migrated forward to
|
|
107
|
+
# FerroxLabs/ijfw on GitHub. Mirrors install.js STALE_PATTERNS.
|
|
108
|
+
'^https://gitlab\.com/therealseandonahoe/ijfw(\.git)?/?$'
|
|
109
|
+
)
|
|
110
|
+
$isStale = $false
|
|
111
|
+
foreach ($pat in $stalePatterns) {
|
|
112
|
+
if ($currentOrigin -imatch $pat) { $isStale = $true; break }
|
|
113
|
+
}
|
|
114
|
+
if ($currentOrigin -and $isStale) {
|
|
99
115
|
Write-Host " origin migration: $currentOrigin -> $DEFAULT_REPO"
|
|
100
116
|
& git -C $target remote set-url origin $DEFAULT_REPO
|
|
117
|
+
if ($LASTEXITCODE -ne 0) {
|
|
118
|
+
Write-Warning " origin migration failed -- could not repoint $currentOrigin to $DEFAULT_REPO"
|
|
119
|
+
}
|
|
101
120
|
}
|
|
102
121
|
# fetch + hard checkout avoids ff-only failures from local divergence.
|
|
103
122
|
& git -C $target fetch --depth 1 origin $branch
|
|
@@ -118,7 +137,13 @@ function Invoke-CloneOrPull($target, $branch) {
|
|
|
118
137
|
try {
|
|
119
138
|
& git clone --depth 1 --branch $branch $DEFAULT_REPO $target
|
|
120
139
|
if ($LASTEXITCODE -ne 0) { throw "Could not reach the IJFW repo (exit $LASTEXITCODE). Check your network connection and retry." }
|
|
121
|
-
|
|
140
|
+
# V155-013: expanded restore allowlist + retain backup if residual content remains
|
|
141
|
+
# so the operator can recover anything the allowlist doesn't know about.
|
|
142
|
+
$restoreAllowlist = @(
|
|
143
|
+
'memory', 'sessions', 'install.log', '.session-counter',
|
|
144
|
+
'ijfw', 'state', 'cache', 'logs', 'run', '.ijfw'
|
|
145
|
+
)
|
|
146
|
+
foreach ($item in $restoreAllowlist) {
|
|
122
147
|
$src = Join-Path $backupDir $item
|
|
123
148
|
if (Test-Path $src) {
|
|
124
149
|
$dst = Join-Path $target $item
|
|
@@ -126,7 +151,13 @@ function Invoke-CloneOrPull($target, $branch) {
|
|
|
126
151
|
Move-Item -LiteralPath $src -Destination $dst -Force
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
|
-
|
|
154
|
+
$residual = Get-ChildItem -LiteralPath $backupDir -Force -ErrorAction SilentlyContinue
|
|
155
|
+
if (-not $residual -or $residual.Count -eq 0) {
|
|
156
|
+
Remove-Item -Recurse -Force -LiteralPath $backupDir -ErrorAction SilentlyContinue
|
|
157
|
+
} else {
|
|
158
|
+
$sample = ($residual | Select-Object -First 8 | ForEach-Object { $_.Name }) -join ', '
|
|
159
|
+
Write-Warning " restored known dirs; backup retained at $backupDir (contains: $sample). Remove manually after verifying."
|
|
160
|
+
}
|
|
130
161
|
return "updated"
|
|
131
162
|
} catch {
|
|
132
163
|
if (Test-Path $target) { Remove-Item -Recurse -Force -LiteralPath $target }
|