mover-os 4.7.5 → 4.7.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/install.js +325 -53
- package/package.json +1 -1
package/install.js
CHANGED
|
@@ -734,7 +734,15 @@ async function activateKey(key) {
|
|
|
734
734
|
if (!key) return;
|
|
735
735
|
try {
|
|
736
736
|
const https = require("https");
|
|
737
|
-
|
|
737
|
+
// v4.7.6: label = stable machine_id (hardware-derived), not os.hostname().
|
|
738
|
+
// Hostnames change (rename a Mac, switch wifi, container restart) and the
|
|
739
|
+
// server-side activation cap counts each hostname as a different machine.
|
|
740
|
+
// machine_id is stable across hostname changes and matches what the
|
|
741
|
+
// server also stores for /api/download X-Machine-Id correlation.
|
|
742
|
+
const moverDir = path.join(os.homedir(), ".mover");
|
|
743
|
+
let label = os.hostname();
|
|
744
|
+
try { label = getMachineId(moverDir) || label; } catch {}
|
|
745
|
+
const body = JSON.stringify({ key: key.trim(), organization_id: POLAR_ORG_ID, label });
|
|
738
746
|
await new Promise((resolve, reject) => {
|
|
739
747
|
const req = https.request({
|
|
740
748
|
hostname: "api.polar.sh",
|
|
@@ -822,11 +830,30 @@ async function downloadPayload(key) {
|
|
|
822
830
|
method: "GET",
|
|
823
831
|
timeout: 60000,
|
|
824
832
|
}, (res2) => {
|
|
825
|
-
|
|
826
|
-
|
|
833
|
+
// v4.7.6 (post-audit): stream-and-abort with running byte counter
|
|
834
|
+
// instead of buffering then checking size. A malicious upstream
|
|
835
|
+
// could send GBs of data and OOM Node before the post-download
|
|
836
|
+
// 100MB stat check fires. Now we destroy the connection the
|
|
837
|
+
// moment the running total exceeds MAX_TARBALL_BYTES.
|
|
838
|
+
const MAX_TARBALL_BYTES = 100 * 1024 * 1024;
|
|
839
|
+
let total = 0;
|
|
840
|
+
const ws = fs.createWriteStream(tarPath);
|
|
841
|
+
let aborted = false;
|
|
842
|
+
res2.on("data", (c) => {
|
|
843
|
+
total += c.length;
|
|
844
|
+
if (total > MAX_TARBALL_BYTES) {
|
|
845
|
+
aborted = true;
|
|
846
|
+
try { res2.destroy(); } catch {}
|
|
847
|
+
try { ws.destroy(); } catch {}
|
|
848
|
+
try { fs.unlinkSync(tarPath); } catch {}
|
|
849
|
+
reject(new Error(`Payload exceeded ${MAX_TARBALL_BYTES} bytes during download`));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
ws.write(c);
|
|
853
|
+
});
|
|
827
854
|
res2.on("end", () => {
|
|
828
|
-
|
|
829
|
-
resolve();
|
|
855
|
+
if (aborted) return;
|
|
856
|
+
ws.end(() => resolve());
|
|
830
857
|
});
|
|
831
858
|
});
|
|
832
859
|
req2.on("error", reject);
|
|
@@ -850,11 +877,26 @@ async function downloadPayload(key) {
|
|
|
850
877
|
reject(new Error(`Download failed (HTTP ${res.statusCode})`));
|
|
851
878
|
return;
|
|
852
879
|
}
|
|
853
|
-
|
|
854
|
-
|
|
880
|
+
// v4.7.6 (post-audit): same stream-and-abort behavior as the redirect path.
|
|
881
|
+
const MAX_TARBALL_BYTES = 100 * 1024 * 1024;
|
|
882
|
+
let total = 0;
|
|
883
|
+
const ws = fs.createWriteStream(tarPath);
|
|
884
|
+
let aborted = false;
|
|
885
|
+
res.on("data", (c) => {
|
|
886
|
+
total += c.length;
|
|
887
|
+
if (total > MAX_TARBALL_BYTES) {
|
|
888
|
+
aborted = true;
|
|
889
|
+
try { res.destroy(); } catch {}
|
|
890
|
+
try { ws.destroy(); } catch {}
|
|
891
|
+
try { fs.unlinkSync(tarPath); } catch {}
|
|
892
|
+
reject(new Error(`Payload exceeded ${MAX_TARBALL_BYTES} bytes during download`));
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
ws.write(c);
|
|
896
|
+
});
|
|
855
897
|
res.on("end", () => {
|
|
856
|
-
|
|
857
|
-
resolve();
|
|
898
|
+
if (aborted) return;
|
|
899
|
+
ws.end(() => resolve());
|
|
858
900
|
});
|
|
859
901
|
});
|
|
860
902
|
req.on("error", reject);
|
|
@@ -862,10 +904,75 @@ async function downloadPayload(key) {
|
|
|
862
904
|
req.end();
|
|
863
905
|
});
|
|
864
906
|
|
|
865
|
-
// Validate tar contents before extraction
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
907
|
+
// ── Validate tar contents before extraction ────────────────────────────
|
|
908
|
+
//
|
|
909
|
+
// SECURITY (v4.7.5 → v4.7.6): zip-slip hardening (commit 2d5cdd2) only
|
|
910
|
+
// checked path strings (absolute paths, `..` traversal). It did NOT check
|
|
911
|
+
// entry TYPE. A crafted tarball could embed a symlink (e.g.,
|
|
912
|
+
// `mover-link -> /etc/passwd`) which `tar -xzf` would happily extract,
|
|
913
|
+
// creating a file outside the destination dir on first follow.
|
|
914
|
+
//
|
|
915
|
+
// Fix: switch to `tar -tvzf` which prints the entry type as the first
|
|
916
|
+
// character (- regular, d directory, l symlink, h hardlink, c char dev,
|
|
917
|
+
// b block dev). Reject anything that isn't a regular file, directory, or
|
|
918
|
+
// long-link metadata (which is followed by a regular entry).
|
|
919
|
+
//
|
|
920
|
+
// Also enforce a 100MB hard cap (compressed) post-download — if the file
|
|
921
|
+
// on disk is bigger than that, refuse before extraction. v4.7.5 had no
|
|
922
|
+
// size check, so a 2GB tarball would OOM Node before validation.
|
|
923
|
+
const MAX_TARBALL_BYTES = 100 * 1024 * 1024;
|
|
924
|
+
const tarStat = fs.statSync(tarPath);
|
|
925
|
+
if (tarStat.size > MAX_TARBALL_BYTES) {
|
|
926
|
+
fs.unlinkSync(tarPath);
|
|
927
|
+
throw new Error(`Payload too large: ${tarStat.size} bytes > ${MAX_TARBALL_BYTES}`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// v4.7.6 (post-audit): use TWO tar listings.
|
|
931
|
+
//
|
|
932
|
+
// 1. `tar -tzf` → paths-only output (one path per line, exact). Used to
|
|
933
|
+
// validate against absolute paths and `..` traversal. v4.7.5's
|
|
934
|
+
// paths-only validation worked here, the regression was the missing
|
|
935
|
+
// type check, not the path parser.
|
|
936
|
+
//
|
|
937
|
+
// 2. `tar -tvzf` → verbose output. Used ONLY to extract the type
|
|
938
|
+
// character (first column). We intentionally do NOT parse the path
|
|
939
|
+
// field from verbose output because BSD tar's verbose format is
|
|
940
|
+
// fragile when paths contain spaces (e.g., "_Template Project/Chats &
|
|
941
|
+
// Resources/...") — last-whitespace-token parsing would silently
|
|
942
|
+
// drop the leading parts of a multi-word path.
|
|
943
|
+
//
|
|
944
|
+
// The ordering of -tvzf and -tzf output is identical (tar walks the
|
|
945
|
+
// archive in the same order), so we zip them by line index for the
|
|
946
|
+
// type/path correlation.
|
|
947
|
+
const pathsListing = execSync(`tar -tzf "${tarPath}"`, { encoding: 'utf8' });
|
|
948
|
+
const verboseListing = execSync(`tar -tvzf "${tarPath}"`, { encoding: 'utf8' });
|
|
949
|
+
const pathLines = pathsListing.split('\n').filter(Boolean);
|
|
950
|
+
const verboseLines = verboseListing.split('\n').filter(Boolean);
|
|
951
|
+
const badPaths = [];
|
|
952
|
+
const badTypes = [];
|
|
953
|
+
if (pathLines.length !== verboseLines.length) {
|
|
954
|
+
throw new Error(`Tar listing inconsistency: ${pathLines.length} path lines vs ${verboseLines.length} verbose lines`);
|
|
955
|
+
}
|
|
956
|
+
for (let i = 0; i < pathLines.length; i++) {
|
|
957
|
+
const entryPath = pathLines[i];
|
|
958
|
+
const typeChar = verboseLines[i].charAt(0);
|
|
959
|
+
if (!entryPath) continue;
|
|
960
|
+
// Reject by type: anything except regular file (-), directory (d).
|
|
961
|
+
// Long-link metadata uses 'L' or 'K'; tar emits these followed by
|
|
962
|
+
// another entry — we'd reject the link metadata here too which is fine
|
|
963
|
+
// because we don't ship long names.
|
|
964
|
+
if (typeChar !== '-' && typeChar !== 'd') {
|
|
965
|
+
badTypes.push(`${typeChar} ${entryPath}`);
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
if (entryPath.startsWith('/') || entryPath.includes('..')) {
|
|
969
|
+
badPaths.push(entryPath);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (badTypes.length > 0) {
|
|
973
|
+
fs.unlinkSync(tarPath);
|
|
974
|
+
throw new Error('Payload contains non-regular entries (symlinks/hardlinks/devices): ' + badTypes.slice(0, 5).join(', '));
|
|
975
|
+
}
|
|
869
976
|
if (badPaths.length > 0) {
|
|
870
977
|
fs.unlinkSync(tarPath);
|
|
871
978
|
throw new Error('Payload contains unsafe paths: ' + badPaths.join(', '));
|
|
@@ -1605,14 +1712,25 @@ async function runUninstall(vaultPath) {
|
|
|
1605
1712
|
const rulesExist = rulesPaths.some(p => fs.existsSync(p.path));
|
|
1606
1713
|
if (rulesExist) categories.push({ id: "rules", name: "Rules", description: "Global rules files for all agents", items: rulesPaths });
|
|
1607
1714
|
|
|
1608
|
-
// Skills
|
|
1715
|
+
// Skills — v4.7.6: cover all agents Mover OS installs into. v4.7.5 only
|
|
1716
|
+
// listed 5 of ~10 paths, leaving orphaned skills behind on uninstall for
|
|
1717
|
+
// Gemini CLI, Cline, Roo Code, Aider, OpenCode, Continue, and the shared
|
|
1718
|
+
// ~/.agents/skills/ pool. Derived from AGENT_REGISTRY, but kept literal
|
|
1719
|
+
// here because uninstall runs after the registry may have changed in
|
|
1720
|
+
// newer bundles. Drift-detection lives in v4.8.0 which moves to a
|
|
1721
|
+
// collectInstallPaths(kind) helper.
|
|
1609
1722
|
const skillsPaths = [
|
|
1610
1723
|
{ label: "Claude Code skills", path: path.join(home, ".claude", "skills"), dir: true, keepBuiltins: true },
|
|
1611
1724
|
{ label: "Cursor skills", path: path.join(home, ".cursor", "skills"), dir: true },
|
|
1612
1725
|
{ label: "Codex skills", path: path.join(home, ".codex", "skills"), dir: true },
|
|
1613
|
-
{ label: "
|
|
1726
|
+
{ label: "Gemini CLI skills", path: path.join(home, ".gemini", "skills"), dir: true },
|
|
1614
1727
|
{ label: "Antigravity skills", path: path.join(home, ".gemini", "antigravity", "skills"), dir: true },
|
|
1615
|
-
|
|
1728
|
+
{ label: "Windsurf skills (legacy)", path: path.join(home, ".windsurf", "skills"), dir: true },
|
|
1729
|
+
{ label: "Windsurf skills", path: path.join(home, ".codeium", "windsurf", "skills"), dir: true },
|
|
1730
|
+
{ label: "Cline skills", path: path.join(home, ".cline", "skills"), dir: true },
|
|
1731
|
+
{ label: "Roo Code skills (vault-relative)", path: vaultPath && path.join(vaultPath, ".roo", "skills"), dir: true },
|
|
1732
|
+
{ label: "Cross-agent shared skills", path: path.join(home, ".agents", "skills"), dir: true },
|
|
1733
|
+
].filter(p => p.path);
|
|
1616
1734
|
const skillsExist = skillsPaths.some(p => fs.existsSync(p.path));
|
|
1617
1735
|
if (skillsExist) categories.push({ id: "skills", name: "Skills", description: "61 curated skill packs", items: skillsPaths });
|
|
1618
1736
|
|
|
@@ -1754,7 +1872,15 @@ async function runUninstall(vaultPath) {
|
|
|
1754
1872
|
barLn(dim("Deactivating license..."));
|
|
1755
1873
|
try {
|
|
1756
1874
|
const https = require("https");
|
|
1757
|
-
|
|
1875
|
+
// v4.7.6: match activateKey's label scheme (machine_id) so deactivate
|
|
1876
|
+
// actually frees the activation slot. v4.7.5 deactivated by hostname,
|
|
1877
|
+
// which only worked if the user hadn't renamed their machine since
|
|
1878
|
+
// install. Fallback: if machine_id read fails, try hostname (covers
|
|
1879
|
+
// pre-v4.7.3 activations that stored hostname).
|
|
1880
|
+
const moverDir = path.join(os.homedir(), ".mover");
|
|
1881
|
+
let label = os.hostname();
|
|
1882
|
+
try { label = getMachineId(moverDir) || label; } catch {}
|
|
1883
|
+
const body = JSON.stringify({ key: cfg.licenseKey, organization_id: POLAR_ORG_ID, label });
|
|
1758
1884
|
await new Promise((resolve, reject) => {
|
|
1759
1885
|
const req = https.request({
|
|
1760
1886
|
hostname: "api.polar.sh",
|
|
@@ -2137,7 +2263,7 @@ const SKILL_CATEGORIES = {
|
|
|
2137
2263
|
"json-canvas": "obsidian",
|
|
2138
2264
|
// Tools (always installed — core utilities)
|
|
2139
2265
|
"defuddle": "tools",
|
|
2140
|
-
"skill-creator": "tools",
|
|
2266
|
+
"mover-skill-creator": "tools",
|
|
2141
2267
|
"find-skills": "tools",
|
|
2142
2268
|
};
|
|
2143
2269
|
|
|
@@ -2151,19 +2277,32 @@ const CATEGORY_META = [
|
|
|
2151
2277
|
{ id: "obsidian", name: "Obsidian", desc: "markdown, bases, canvas, CLI" },
|
|
2152
2278
|
];
|
|
2153
2279
|
|
|
2280
|
+
// Dev/eval artifacts that ship in src/skills/ but must NEVER be installed as
|
|
2281
|
+
// runtime skills. Matched as a directory name suffix so e.g.
|
|
2282
|
+
// `friction-enforcer-workspace/iteration-1/skill-snapshot/SKILL.md` (an evaluation
|
|
2283
|
+
// snapshot of friction-enforcer) does not get installed as a separate skill named
|
|
2284
|
+
// `skill-snapshot`. Caused 1+ duplicate skill in v4.7.5 installs which compounded
|
|
2285
|
+
// the skill-description budget pressure that drops descriptions at runtime.
|
|
2286
|
+
const SKILL_DEV_DIR_SUFFIXES = ["-workspace", "-benchmark", "-sandbox"];
|
|
2287
|
+
|
|
2288
|
+
function isDevSkillDir(name) {
|
|
2289
|
+
return SKILL_DEV_DIR_SUFFIXES.some((s) => name.endsWith(s));
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2154
2292
|
function findSkills(bundleDir) {
|
|
2155
2293
|
const skillsDir = path.join(bundleDir, "src", "skills");
|
|
2156
2294
|
if (!fs.existsSync(skillsDir)) return [];
|
|
2157
2295
|
const skills = [];
|
|
2158
2296
|
const walk = (dir) => {
|
|
2159
2297
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2298
|
+
if (!entry.isDirectory()) continue;
|
|
2299
|
+
// Skip dev/eval artifact roots and anything inside them.
|
|
2300
|
+
if (isDevSkillDir(entry.name)) continue;
|
|
2160
2301
|
const full = path.join(dir, entry.name);
|
|
2161
|
-
if (
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
walk(full);
|
|
2166
|
-
}
|
|
2302
|
+
if (fs.existsSync(path.join(full, "SKILL.md"))) {
|
|
2303
|
+
skills.push({ name: entry.name, path: full, category: SKILL_CATEGORIES[entry.name] || "tools" });
|
|
2304
|
+
} else {
|
|
2305
|
+
walk(full);
|
|
2167
2306
|
}
|
|
2168
2307
|
}
|
|
2169
2308
|
};
|
|
@@ -2264,9 +2403,15 @@ function generateCodexHooks() {
|
|
|
2264
2403
|
matcher: "startup|resume|clear",
|
|
2265
2404
|
hooks: [
|
|
2266
2405
|
{
|
|
2406
|
+
// v4.7.6: switched from "full" to "resume" mode. The full primer
|
|
2407
|
+
// is ~12K chars which mover-hook-adapter.js wraps as
|
|
2408
|
+
// additionalContext for Codex, eating the skill-description
|
|
2409
|
+
// budget. Codex sessions only need lightweight context refresh.
|
|
2410
|
+
// Timeout 15s (was 5s): session-start.sh:122 calls `npm view`
|
|
2411
|
+
// on cold cache; 5s killed cold-start under Codex.
|
|
2267
2412
|
type: "command",
|
|
2268
|
-
command: `node ${adapter} codex SessionStart ${hookDir}/session-start.sh"
|
|
2269
|
-
timeout:
|
|
2413
|
+
command: `node ${adapter} codex SessionStart ${hookDir}/session-start.sh" resume`,
|
|
2414
|
+
timeout: 15,
|
|
2270
2415
|
},
|
|
2271
2416
|
],
|
|
2272
2417
|
},
|
|
@@ -2341,10 +2486,13 @@ function generateGeminiHooks() {
|
|
|
2341
2486
|
matcher: "startup",
|
|
2342
2487
|
hooks: [
|
|
2343
2488
|
{
|
|
2489
|
+
// v4.7.6: same fix as the Codex hook — switched from "full" to
|
|
2490
|
+
// "resume" to keep mover-hook-adapter additionalContext within
|
|
2491
|
+
// budget. Timeout raised to 15s for cold-cache npm view fallback.
|
|
2344
2492
|
name: "mover-session-start",
|
|
2345
2493
|
type: "command",
|
|
2346
|
-
command: `node ${adapter} gemini SessionStart ${hookDir}/session-start.sh"
|
|
2347
|
-
timeout:
|
|
2494
|
+
command: `node ${adapter} gemini SessionStart ${hookDir}/session-start.sh" resume`,
|
|
2495
|
+
timeout: 15000,
|
|
2348
2496
|
},
|
|
2349
2497
|
],
|
|
2350
2498
|
},
|
|
@@ -3029,6 +3177,20 @@ function installSkillPacks(bundleDir, destDir, selectedCategories) {
|
|
|
3029
3177
|
// Skip unchanged skills
|
|
3030
3178
|
const sourceHash = computeSkillHash(skill.path);
|
|
3031
3179
|
if (manifest.skills[skill.name]?.hash === sourceHash && fs.existsSync(dest)) {
|
|
3180
|
+
// v4.7.6 (post-audit): even when skipping, ensure the .mover-installed
|
|
3181
|
+
// stamp exists. Pre-v4.7.6 installs lack the stamp; without this
|
|
3182
|
+
// backfill, the manifest+stamp logic in the orphan cleanup wouldn't
|
|
3183
|
+
// see them as Mover-owned on the first v4.7.6 update if the manifest
|
|
3184
|
+
// entry was lost (rare but possible).
|
|
3185
|
+
try {
|
|
3186
|
+
const stampPath = path.join(dest, ".mover-installed");
|
|
3187
|
+
if (!fs.existsSync(stampPath)) {
|
|
3188
|
+
fs.writeFileSync(
|
|
3189
|
+
stampPath,
|
|
3190
|
+
JSON.stringify({ name: skill.name, version: "v4.7.6", at: new Date().toISOString(), backfilled: true }, null, 2)
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
} catch {}
|
|
3032
3194
|
installedNames.add(skill.name);
|
|
3033
3195
|
skipped++;
|
|
3034
3196
|
continue;
|
|
@@ -3036,23 +3198,40 @@ function installSkillPacks(bundleDir, destDir, selectedCategories) {
|
|
|
3036
3198
|
|
|
3037
3199
|
if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
|
|
3038
3200
|
copyDirRecursive(skill.path, dest);
|
|
3201
|
+
// v4.7.6: stamp file proves Mover OS owns this skill. Used by orphan
|
|
3202
|
+
// cleanup below — only stamped skills are deletable on update. User-
|
|
3203
|
+
// created skills (e.g., a skill the user wrote and dropped into the
|
|
3204
|
+
// skills dir) survive even if their SKILL.md happens to contain
|
|
3205
|
+
// "## Activation" or "## When to Use" headings.
|
|
3206
|
+
try {
|
|
3207
|
+
fs.writeFileSync(
|
|
3208
|
+
path.join(dest, ".mover-installed"),
|
|
3209
|
+
JSON.stringify({ name: skill.name, version: "v4.7.6", at: new Date().toISOString() }, null, 2)
|
|
3210
|
+
);
|
|
3211
|
+
} catch {}
|
|
3039
3212
|
manifest.skills[skill.name] = { hash: sourceHash, installedAt: new Date().toISOString() };
|
|
3040
3213
|
installedNames.add(skill.name);
|
|
3041
3214
|
count++;
|
|
3042
3215
|
}
|
|
3043
3216
|
|
|
3044
|
-
// Clean orphaned skills (renamed/removed in updates)
|
|
3045
|
-
//
|
|
3217
|
+
// Clean orphaned skills (renamed/removed in updates).
|
|
3218
|
+
// v4.7.6: ONLY remove skills that have a `.mover-installed` stamp from a
|
|
3219
|
+
// prior install OR appear in the manifest. User-created skills (no stamp,
|
|
3220
|
+
// not in manifest) are preserved regardless of their SKILL.md contents.
|
|
3221
|
+
// Pre-v4.7.6 installs lack the stamp; the manifest entry covers those
|
|
3222
|
+
// (manifest is written for every prior install). After one cycle of v4.7.6
|
|
3223
|
+
// install/update, every shipped skill has both stamp and manifest entry.
|
|
3046
3224
|
for (const dir of fs.readdirSync(destDir)) {
|
|
3047
3225
|
if (installedNames.has(dir)) continue;
|
|
3048
3226
|
const dirPath = path.join(destDir, dir);
|
|
3049
3227
|
try {
|
|
3050
|
-
if (fs.statSync(dirPath).isDirectory()
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
}
|
|
3228
|
+
if (!fs.statSync(dirPath).isDirectory()) continue;
|
|
3229
|
+
if (!fs.existsSync(path.join(dirPath, "SKILL.md"))) continue;
|
|
3230
|
+
const hasStamp = fs.existsSync(path.join(dirPath, ".mover-installed"));
|
|
3231
|
+
const inManifest = Boolean(manifest.skills[dir]);
|
|
3232
|
+
if (hasStamp || inManifest) {
|
|
3233
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
3234
|
+
ln(` ${dim("Removed orphan skill:")} ${dir}`);
|
|
3056
3235
|
}
|
|
3057
3236
|
} catch (e) { /* skip */ }
|
|
3058
3237
|
}
|
|
@@ -3105,10 +3284,15 @@ function installHooksForClaude(bundleDir, vaultPath) {
|
|
|
3105
3284
|
if (!existing.hooks[event]) {
|
|
3106
3285
|
existing.hooks[event] = entries;
|
|
3107
3286
|
} else {
|
|
3108
|
-
// Check if our hooks are already registered (by command substring)
|
|
3287
|
+
// Check if our hooks are already registered (by command substring).
|
|
3288
|
+
// v4.7.6: split on both / and \\ so the basename extraction works on
|
|
3289
|
+
// Windows where command paths use backslashes. The forward-slash-only
|
|
3290
|
+
// split returned the entire command string on Windows, which never
|
|
3291
|
+
// matched the substring check, leading to duplicate hooks accumulating
|
|
3292
|
+
// on every install.
|
|
3109
3293
|
const existingCmds = JSON.stringify(existing.hooks[event]);
|
|
3110
3294
|
const alreadyHas = entries[0].hooks.every(
|
|
3111
|
-
(h) => existingCmds.includes(h.command.split(
|
|
3295
|
+
(h) => existingCmds.includes(h.command.split(/[\\/]/).pop().replace('"', ""))
|
|
3112
3296
|
);
|
|
3113
3297
|
if (!alreadyHas) {
|
|
3114
3298
|
existing.hooks[event].push(...entries);
|
|
@@ -3325,10 +3509,18 @@ function installHooksForGemini(bundleDir) {
|
|
|
3325
3509
|
return count;
|
|
3326
3510
|
}
|
|
3327
3511
|
|
|
3328
|
-
// Per-entry hook merge.
|
|
3329
|
-
//
|
|
3330
|
-
//
|
|
3331
|
-
//
|
|
3512
|
+
// Per-entry hook merge.
|
|
3513
|
+
//
|
|
3514
|
+
// v4.7.5 strategy was: skip if an entry with same matcher + same exact commands
|
|
3515
|
+
// exists. That prevented duplicates on re-run BUT also meant existing v4.7.5
|
|
3516
|
+
// installs with broken commands (e.g. `session-start.sh full` instead of the
|
|
3517
|
+
// v4.7.6 `session-start.sh resume`) never got upgraded — the merge saw a Mover
|
|
3518
|
+
// entry and skipped, leaving the old broken hook in place.
|
|
3519
|
+
//
|
|
3520
|
+
// v4.7.6 strategy: identify Mover entries by command containing
|
|
3521
|
+
// "mover-hook-adapter.js" or "src/hooks/" path. For those, REPLACE the existing
|
|
3522
|
+
// entry with the new one. For non-Mover entries (user customizations), leave
|
|
3523
|
+
// untouched and append the new Mover entry alongside.
|
|
3332
3524
|
function mergeHooksConfig(existing, newConfig) {
|
|
3333
3525
|
if (!existing.hooks) existing.hooks = {};
|
|
3334
3526
|
for (const [event, newEntries] of Object.entries(newConfig.hooks || {})) {
|
|
@@ -3338,15 +3530,32 @@ function mergeHooksConfig(existing, newConfig) {
|
|
|
3338
3530
|
}
|
|
3339
3531
|
for (const newEntry of newEntries) {
|
|
3340
3532
|
const matcher = newEntry.matcher; // may be undefined
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3533
|
+
// v4.7.6 (post-audit): we identify the matching matcher entry and
|
|
3534
|
+
// replace ONLY the Mover-owned commands inside it. Non-Mover commands
|
|
3535
|
+
// a user has added at the same matcher level are preserved. The prior
|
|
3536
|
+
// approach replaced the whole entry, wiping user customizations.
|
|
3537
|
+
const isMoverCmd = (c) =>
|
|
3538
|
+
typeof c === "string" &&
|
|
3539
|
+
(c.includes("mover-hook-adapter.js") || c.includes("src/hooks/"));
|
|
3540
|
+
const matcherIdx = existing.hooks[event].findIndex(
|
|
3541
|
+
(e) => (e.matcher || "") === (matcher || "")
|
|
3542
|
+
);
|
|
3543
|
+
if (matcherIdx < 0) {
|
|
3544
|
+
// No entry at this matcher — append cleanly.
|
|
3545
|
+
existing.hooks[event].push(newEntry);
|
|
3546
|
+
continue;
|
|
3547
|
+
}
|
|
3548
|
+
const matcherEntry = existing.hooks[event][matcherIdx];
|
|
3549
|
+
if (!Array.isArray(matcherEntry.hooks)) matcherEntry.hooks = [];
|
|
3550
|
+
// Drop existing Mover-owned hooks (we will re-add the new versions).
|
|
3551
|
+
// Keep every user-owned hook untouched.
|
|
3552
|
+
matcherEntry.hooks = matcherEntry.hooks.filter(
|
|
3553
|
+
(h) => !isMoverCmd(h && h.command)
|
|
3554
|
+
);
|
|
3555
|
+
// Append the fresh Mover hooks for this matcher.
|
|
3556
|
+
for (const h of newEntry.hooks || []) {
|
|
3557
|
+
if (isMoverCmd(h && h.command)) matcherEntry.hooks.push(h);
|
|
3558
|
+
}
|
|
3350
3559
|
}
|
|
3351
3560
|
}
|
|
3352
3561
|
}
|
|
@@ -3537,9 +3746,40 @@ function installWindsurf(bundleDir, vaultPath, skillOpts) {
|
|
|
3537
3746
|
}
|
|
3538
3747
|
|
|
3539
3748
|
if (skillOpts && skillOpts.install) {
|
|
3540
|
-
|
|
3749
|
+
// v4.7.6: standardize on the registry-canonical Windsurf path
|
|
3750
|
+
// (~/.codeium/windsurf/skills). v4.7.5 wrote to ~/.windsurf/skills which
|
|
3751
|
+
// conflicted with the registry entry and meant skills were duplicated
|
|
3752
|
+
// across both paths on machines that had been installed pre-v4.7.5.
|
|
3753
|
+
const skillsDir = AGENT_REGISTRY.windsurf.skills.dest();
|
|
3754
|
+
const legacySkillsDir = path.join(home, ".windsurf", "skills");
|
|
3541
3755
|
const skCount = installSkillPacks(bundleDir, skillsDir, skillOpts.categories);
|
|
3542
3756
|
if (skCount > 0) steps.push(`${skCount} skills`);
|
|
3757
|
+
|
|
3758
|
+
// Migrate from legacy ~/.windsurf/skills.
|
|
3759
|
+
// v4.7.6 (post-audit): only delete legacy entries that have a
|
|
3760
|
+
// .mover-installed stamp file (proving Mover OS owns them). Same-name
|
|
3761
|
+
// user-created skills in the legacy path that lack the stamp are
|
|
3762
|
+
// preserved. The prior "same-name = delete" logic risked nuking
|
|
3763
|
+
// divergent user skills.
|
|
3764
|
+
if (fs.existsSync(legacySkillsDir) && legacySkillsDir !== skillsDir) {
|
|
3765
|
+
try {
|
|
3766
|
+
for (const entry of fs.readdirSync(legacySkillsDir)) {
|
|
3767
|
+
const legacyEntry = path.join(legacySkillsDir, entry);
|
|
3768
|
+
const canonicalEntry = path.join(skillsDir, entry);
|
|
3769
|
+
if (!fs.statSync(legacyEntry).isDirectory()) continue;
|
|
3770
|
+
if (!fs.existsSync(canonicalEntry)) continue;
|
|
3771
|
+
// Require a .mover-installed stamp at the legacy path for delete.
|
|
3772
|
+
const legacyStamp = path.join(legacyEntry, ".mover-installed");
|
|
3773
|
+
if (fs.existsSync(legacyStamp)) {
|
|
3774
|
+
fs.rmSync(legacyEntry, { recursive: true, force: true });
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
// Remove empty legacy parent dir.
|
|
3778
|
+
try {
|
|
3779
|
+
if (fs.readdirSync(legacySkillsDir).length === 0) fs.rmdirSync(legacySkillsDir);
|
|
3780
|
+
} catch {}
|
|
3781
|
+
} catch {}
|
|
3782
|
+
}
|
|
3543
3783
|
}
|
|
3544
3784
|
|
|
3545
3785
|
return steps;
|
|
@@ -3567,8 +3807,40 @@ function installGeminiCli(bundleDir, vaultPath, skillOpts, writtenFiles) {
|
|
|
3567
3807
|
if (wfCount > 0) steps.push(`${wfCount} commands`);
|
|
3568
3808
|
|
|
3569
3809
|
if (skillOpts && skillOpts.install) {
|
|
3570
|
-
const
|
|
3810
|
+
const geminiSkillsDir = path.join(geminiDir, "skills");
|
|
3811
|
+
const skCount = installSkillPacks(bundleDir, geminiSkillsDir, skillOpts.categories);
|
|
3571
3812
|
if (skCount > 0) steps.push(`${skCount} skills`);
|
|
3813
|
+
|
|
3814
|
+
// v4.7.6: clean up duplicate Mover skills in ~/.agents/skills/.
|
|
3815
|
+
//
|
|
3816
|
+
// Gemini CLI scans both ~/.gemini/skills/ (canonical) AND
|
|
3817
|
+
// ~/.agents/skills/ (cross-agent shared pool) at session start. If a Mover
|
|
3818
|
+
// install has populated both, Gemini emits a wall of "Skill conflict
|
|
3819
|
+
// detected: ... is overriding ..." warnings. Worse, every shipped skill
|
|
3820
|
+
// takes a budget slot twice in the agent's effective skill manifest.
|
|
3821
|
+
//
|
|
3822
|
+
// Fix: after writing to the canonical path, scan ~/.agents/skills/ for
|
|
3823
|
+
// entries that we just installed (same name as a directory we own at the
|
|
3824
|
+
// canonical path) and remove only those. User skills in ~/.agents/skills/
|
|
3825
|
+
// (Amp/etc.) stay untouched.
|
|
3826
|
+
// v4.7.6 (post-audit): require a `.mover-installed` stamp on the shared
|
|
3827
|
+
// entry before deleting. Prior "same-name = delete" risked nuking
|
|
3828
|
+
// user-created divergent skills with the same folder name.
|
|
3829
|
+
const sharedSkillsDir = path.join(home, ".agents", "skills");
|
|
3830
|
+
if (fs.existsSync(sharedSkillsDir) && sharedSkillsDir !== geminiSkillsDir) {
|
|
3831
|
+
try {
|
|
3832
|
+
for (const entry of fs.readdirSync(sharedSkillsDir)) {
|
|
3833
|
+
const sharedEntry = path.join(sharedSkillsDir, entry);
|
|
3834
|
+
const canonicalEntry = path.join(geminiSkillsDir, entry);
|
|
3835
|
+
if (!fs.statSync(sharedEntry).isDirectory()) continue;
|
|
3836
|
+
if (!fs.existsSync(canonicalEntry)) continue;
|
|
3837
|
+
const sharedStamp = path.join(sharedEntry, ".mover-installed");
|
|
3838
|
+
if (fs.existsSync(sharedStamp)) {
|
|
3839
|
+
fs.rmSync(sharedEntry, { recursive: true, force: true });
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
} catch {}
|
|
3843
|
+
}
|
|
3572
3844
|
}
|
|
3573
3845
|
|
|
3574
3846
|
// v4.7.5: Native Gemini hook support via mover-hook-adapter.js
|