mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* feat(setup): add Factory Droid (MCP + skills) to gitnexus setup Register 'droid' in the editor-targets abstraction so `gitnexus setup -c droid` writes the MCP server to ~/.factory/mcp.json and installs skills to ~/.factory/skills/ from the single canonical skills/ source (no per-editor copies). uninstall.ts is target-driven, so removal is covered automatically. Adds unit + round-trip coverage. * feat(plugin): add gitnexus-factory-plugin for droid plugin install * docs: add Factory Droid to editor support table and setup docs * fix(factory-plugin): guard augment hook against fan-out and DB contention Reuse the Claude adapter's acquireHookSlot and LadybugDB owner probe (bundled byte-identical, kept in lockstep by a drift test) instead of running an unguarded augment. Add direct tests for the hook and manifests. * docs: align Factory row in editor support table * fix(factory-plugin): honor GITNEXUS_HOOK_CLI_PATH so augment runs on Windows * docs(hooks): point bundled guard copies at their drift tests * docs(factory-plugin): note the Execute tokenizer's quoting limit * docs(readme): clarify the Full tier and group the Factory row * docs(hooks): trim drift note to a single line * test(ci): run factory-plugin tests on the windows cross-platform lane * refactor(hooks): drop the drift-note comments, the tests already enforce it * fix(factory-plugin): pin CLI version and parse quoted shell patterns - Pin mcp.json and the hook's npx fallback to gitnexus@<version> from the plugin manifest, registered with the release sync script so a mutable @latest can never execute on MCP connect or augment fallback - Port the #2938 shell tokenizer (tokenizeShellWords + parseRgGrepPattern) so quoted, backslash-escaped, --regexp=, -eVALUE, and -- patterns survive - Add the #2938 regression matrix and pin assertions to factory-plugin.test.ts * docs: add Factory Droid to published npm README * fix(factory-plugin): wire marketplace so droid installs the Factory plugin Add .factory-plugin/marketplace.json sourcing ./gitnexus-factory-plugin. Droid reads it before .claude-plugin/marketplace.json, so `droid plugin install` now delivers the Factory plugin (Execute matcher, pinned mcp.json) instead of the translated Claude plugin (Bash matcher, gitnexus@latest). Register the surface in the version-sync script and cover the wiring in the factory and sync test suites. * fix(factory-plugin): use registry lookup for index resolution Bundle registry-query.cjs so external indexes resolve (#3060); re-pin to 1.6.12. * fix(factory-plugin): sync Execute parser with Cursor hook Fixes echo-rg and -f false positives; tighten test env isolation. * fix(factory-plugin): stop no-match augment from re-running via npx A PATH `gitnexus` that finds no match exits 0 with empty stderr, which fell through to a second `npx -y gitnexus@<pin> augment` with its own 8s timeout (16s worst case vs the 10s hook budget). Fall through to npx only when the PATH launcher is missing (ENOENT); any launched PATH binary, including a timeout or non-zero exit, now ends the augment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(factory-plugin): filter augment stderr to the [GitNexus] block runAugment returned raw child stderr, so npm/Node/LadybugDB warnings leaked into additionalContext and noise-only stderr counted as success. Port the Claude adapter's extractAugmentContext (verbatim, with isDebugEnabled) and apply it on every launch tier before the success decision. Adds a drift test against the Claude copy and PATH-tier noise/noise-only behavior tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(factory-plugin): quote DROID_PLUGIN_ROOT in hook command An unquoted plugin root containing spaces (e.g. a Windows user profile path) split into multiple argv words, so the PostToolUse hook silently never ran. Quote it like the Claude plugin does, and pin the exact quoted command in the hooks.json wiring test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(release): stage Factory plugin manifests in the release commit The rc release job stages only the original four manifest surfaces in the detached release commit, so the v<version> tag tree carried the Factory plugin.json, mcp.json and marketplace.json at the previous version while --check (working tree) passed. Stage them too, and guard the git add block against the synced surfaces in sync-plugin-manifests.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(factory-plugin): simplify hook gates, spawn tiers and tests - main(): resolve the repo only after the tool-name and pattern gates, matching the Claude/Cursor hook order (skips fs/git work on no-op calls). - runAugment(): share one spawnAugment helper between the GITNEXUS_HOOK_CLI_PATH and npx tiers; PATH tier ENOENT logic unchanged. - factory-plugin test: pre-filter comment lines instead of `continue`. - sync-plugin-manifests test: hoist EXECUTABLE_MCP_FILES and derive TOTAL_SURFACES from its length. - fnSource(): throw when the function or its closing brace is not found. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli): list Factory Droid in localized setup help `localizeCliHelp` overwrites the `setup` command description with the `help.command.setup.description` i18n key, so the literal edited in index.ts never reached `gitnexus setup --help`. Add Factory Droid to the en and zh-CN keys. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Address PR review feedback (#2543) - factory hook: run every augment tier under the bundled Unix timeout guard (npx tier group-kills), keeping exactly-one-tier fall-through - hook-db-lock-probe: trim GITNEXUS_HOOK_{LSOF,PS}_PATH once so a padded override is used, not silently replaced (all 3 copies) - hook-lock: evict a stale slot via rename-to-tombstone + identity check, so a concurrently recreated fresh lock is never deleted (all 4 copies) - registry-query: a set-but-invalid storage override resolves no repo instead of falling back to the registry storagePath (all 4 copies) - publish.yml: stage the ten skill mcp.json manifests in the rc release commit; the staging test now requires every synced surface Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Address PR review feedback round 2 (#2543) - hook-lock: replace rename-to-tombstone eviction with an O_EXCL per-slot `.evicting` marker plus an identity re-check before unlink, so a live lock is never moved, and a crashed evictor leaves only a self-expiring marker (all 4 copies) - hook-db-lock-probe: clamp GITNEXUS_HOOK_PROC_CMDLINE_MAX to a named 256 KiB ceiling and require an integer, so an oversized override can no longer fail the buffer allocation and miss a live owner (all 3 copies) - registry-query: treat an empty GITNEXUS_STORAGE_PATH/ROOT as set but invalid, matching the CLI's `!== undefined` rule (all 4 copies); the factory test env now deletes those keys instead of blanking them Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Address PR review feedback round 3 (#2543) - hook-db-lock-probe: a capped /proc cmdline read stops early only once both the GitNexus token and the mcp/serve mode are present (or at EOF, the ceiling, or the budget), so a mode word such as `--require mcp` before the GitNexus path no longer hides a live owner (all 3 copies) - registry-query: correct the override comment; a filesystem root is invalid only for GITNEXUS_STORAGE_PATH, not GITNEXUS_STORAGE_ROOT (all 4 copies, comment only) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Address PR review feedback round 4 (#2543) - hook-db-lock-probe: an fd-directory read error other than ENOENT or ENOTDIR on an identified server candidate now fails closed ('timeout') instead of reporting not-owned (EMFILE/ENFILE/ENOMEM/EINTR) - hook-db-lock-probe: resolve GITNEXUS_HOOK_TIMEOUT_PATH to an absolute path before validating and caching it, so callers that spawn with a request cwd can still execute the guard - hook-db-lock-probe: document the chunked cmdline read's actual stop conditions (all 3 copies) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Harden hook-lock eviction marker lifecycle (#2543) Per the chosen option (B) for the stale-slot eviction race: - `.evicting` markers carry a per-call owner token (pid + random hex) - an evictor re-reads its token immediately before the slot identity check and unlink; a stalled evictor whose marker was broken backs off - `finally` removes the marker only while it still holds our token - an orphaned marker is broken only if, re-checked just before unlink, its bigint identity and token are unchanged from when judged stale - doc comment states the two remaining two-syscall windows (slot lstat->unlink, marker token->unlink); POSIX has no conditional unlink, and the worst case is one extra concurrent augment All four byte-identical hook-lock copies updated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Fix CodeQL file-system race in hook-lock orphan-marker check (#2543) breakOrphanedMarker stat'd the marker by path and then read it by path, which CodeQL flags (js/file-system-race): the file could be replaced between the two calls. Take the stat and the token from one open descriptor (readMarkerSnapshot, O_NOFOLLOW where available) for both the "judged stale" snapshot and the pre-unlink re-check. All four hook-lock copies updated. The replaced-marker test injected its swap via a readFileSync(path) spy, which no longer fires; it now swaps the marker just before its second open, counting opens of the marker path only (a per-path counter fired early on slot-0 and let a mutant pass). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Unregister hook-lock exit listener on release (#2543) Each acquireHookSlot registered `release` as a process 'exit' listener that was never removed, so a long-lived process acquiring and releasing slots repeatedly would accumulate listeners (MaxListenersExceededWarning) and retain every closure. release() now removes itself. All four hook-lock copies updated; a test asserts 12 acquire/release cycles leave the 'exit' listener count unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
205 lines
8.4 KiB
JavaScript
205 lines
8.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Fail-closed version sync for the plugin manifest surfaces (#2445).
|
|
*
|
|
* `publish.yml` bumps only `gitnexus/package.json` when it cuts an RC, so
|
|
* every RC tag through v1.6.10-rc.28 shipped manifests frozen at the last
|
|
* stable version and failed its own unit suite (the cli-commands version
|
|
* contract). This script pins every version-bearing plugin surface to the
|
|
* package version:
|
|
*
|
|
* - gitnexus-claude-plugin/.claude-plugin/plugin.json (top-level version)
|
|
* - .claude-plugin/marketplace.json (plugins[gitnexus])
|
|
* - gitnexus-claude-plugin/.codex-plugin/plugin.json (top-level version)
|
|
* - .agents/plugins/marketplace.json (plugins[gitnexus])
|
|
* - gitnexus-claude-plugin/skills/<skill>/mcp.json (gitnexus@<version> launch arg, x10)
|
|
* - gitnexus-factory-plugin/.factory-plugin/plugin.json (top-level version)
|
|
* - gitnexus-factory-plugin/mcp.json (gitnexus@<version> launch arg)
|
|
* - .factory-plugin/marketplace.json (plugins[gitnexus])
|
|
*
|
|
* Modes:
|
|
* node scripts/sync-plugin-manifests.mjs rewrite stale surfaces
|
|
* node scripts/sync-plugin-manifests.mjs --check verify only, exit 1 on drift
|
|
*
|
|
* Fail-closed: a missing file, unparseable JSON, an absent version field, or
|
|
* anything other than exactly one `gitnexus` marketplace entry aborts with a
|
|
* non-zero exit rather than letting a release ship a partial sync.
|
|
*/
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// Plugin skill mcp.json are executable MCP definitions (they launch
|
|
// `npx -y gitnexus@<version> mcp` when a skill starts), not quickstart docs, so
|
|
// they must ship pinned to the released version and be auto-stamped here like
|
|
// the other surfaces — otherwise every skill invocation pulls whatever owns
|
|
// `gitnexus@latest`, independent of the reviewed plugin version. All ten are
|
|
// kept byte-identical (the shipped-skills-sync drift guard enforces it).
|
|
const PLUGIN_SKILL_MCP_DIRS = [
|
|
'gitnexus-plan',
|
|
'gitnexus-work',
|
|
'gitnexus-review',
|
|
'gitnexus-lfg',
|
|
'gitnexus-guide',
|
|
'gitnexus-cli',
|
|
'gitnexus-debugging',
|
|
'gitnexus-exploring',
|
|
'gitnexus-impact-analysis',
|
|
'gitnexus-refactoring',
|
|
];
|
|
|
|
const MANIFEST_SURFACES = [
|
|
{ file: 'gitnexus-claude-plugin/.claude-plugin/plugin.json', kind: 'plugin' },
|
|
{ file: '.claude-plugin/marketplace.json', kind: 'marketplace' },
|
|
{ file: 'gitnexus-claude-plugin/.codex-plugin/plugin.json', kind: 'plugin' },
|
|
{ file: '.agents/plugins/marketplace.json', kind: 'marketplace' },
|
|
...PLUGIN_SKILL_MCP_DIRS.map((name) => ({
|
|
file: `gitnexus-claude-plugin/skills/${name}/mcp.json`,
|
|
kind: 'mcp',
|
|
})),
|
|
// The Factory plugin's manifest is also its pin source at runtime: the
|
|
// PostToolUse hook reads this version to build its `npx -y gitnexus@<version>`
|
|
// fallback, so stamping it here keeps the hook and the MCP entry on the same
|
|
// released CLI.
|
|
{ file: 'gitnexus-factory-plugin/.factory-plugin/plugin.json', kind: 'plugin' },
|
|
{ file: 'gitnexus-factory-plugin/mcp.json', kind: 'mcp' },
|
|
// Droid reads .factory-plugin/marketplace.json before .claude-plugin's, so
|
|
// this entry is what makes `droid plugin install` deliver the Factory plugin
|
|
// (Execute matcher, pinned mcp.json) instead of the translated Claude one.
|
|
{ file: '.factory-plugin/marketplace.json', kind: 'marketplace' },
|
|
];
|
|
|
|
const PLUGIN_NAME = 'gitnexus';
|
|
|
|
function readJson(filePath) {
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(filePath, 'utf8');
|
|
} catch (err) {
|
|
throw new Error(`Cannot read manifest surface ${filePath}: ${err.message}`);
|
|
}
|
|
try {
|
|
return { raw, parsed: JSON.parse(raw) };
|
|
} catch (err) {
|
|
throw new Error(`Manifest surface ${filePath} is not valid JSON: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function versionTarget(manifest, kind, filePath) {
|
|
if (kind === 'plugin') {
|
|
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
|
|
throw new Error(`Manifest surface ${filePath} has no version field to sync`);
|
|
}
|
|
return manifest;
|
|
}
|
|
const entries = (Array.isArray(manifest.plugins) ? manifest.plugins : []).filter(
|
|
(plugin) => plugin?.name === PLUGIN_NAME,
|
|
);
|
|
if (entries.length !== 1) {
|
|
throw new Error(
|
|
`Manifest surface ${filePath} must contain exactly one "${PLUGIN_NAME}" plugin entry, found ${entries.length}`,
|
|
);
|
|
}
|
|
if (typeof entries[0].version !== 'string' || entries[0].version.length === 0) {
|
|
throw new Error(`Manifest surface ${filePath} has no version field to sync`);
|
|
}
|
|
return entries[0];
|
|
}
|
|
|
|
/**
|
|
* Resolve the current pinned version and the textual needle for one surface.
|
|
* `plugin`/`marketplace` pin a JSON `"version"` field; `mcp` pins the version
|
|
* inside the `gitnexus@<version>` launch arg. Returns `{ from, needle }` where
|
|
* `needle(v)` renders the exact substring to match/replace for version `v`.
|
|
* Fail-closed on a missing/ambiguous target.
|
|
*/
|
|
function versionInfo(manifest, kind, filePath) {
|
|
if (kind === 'mcp') {
|
|
const server = manifest?.mcpServers?.[PLUGIN_NAME];
|
|
const args = Array.isArray(server?.args) ? server.args : [];
|
|
const pins = args.filter((arg) => typeof arg === 'string' && arg.startsWith(`${PLUGIN_NAME}@`));
|
|
if (pins.length !== 1) {
|
|
throw new Error(
|
|
`Manifest surface ${filePath} must contain exactly one "${PLUGIN_NAME}@<version>" launch arg, found ${pins.length}`,
|
|
);
|
|
}
|
|
const from = pins[0].slice(`${PLUGIN_NAME}@`.length);
|
|
if (from.length === 0) {
|
|
throw new Error(`Manifest surface ${filePath} has an empty ${PLUGIN_NAME}@ version`);
|
|
}
|
|
return { from, needle: (value) => `${PLUGIN_NAME}@${value}` };
|
|
}
|
|
const target = versionTarget(manifest, kind, filePath);
|
|
return { from: target.version, needle: (value) => `"version": "${value}"` };
|
|
}
|
|
|
|
/**
|
|
* Sync (or with `check: true`, only inspect) every manifest surface under
|
|
* `rootDir`. Returns `{ version, synced, stale }` where `stale` lists the
|
|
* surfaces that did not match the package version when the run started.
|
|
*/
|
|
export function syncPluginManifests(rootDir, { check = false } = {}) {
|
|
const pkgPath = path.join(rootDir, 'gitnexus', 'package.json');
|
|
const version = readJson(pkgPath).parsed.version;
|
|
if (typeof version !== 'string' || version.length === 0) {
|
|
throw new Error(`No version found in ${pkgPath}`);
|
|
}
|
|
|
|
const synced = [];
|
|
const stale = [];
|
|
for (const { file, kind } of MANIFEST_SURFACES) {
|
|
const manifestPath = path.join(rootDir, file);
|
|
const { raw, parsed } = readJson(manifestPath);
|
|
const { from, needle } = versionInfo(parsed, kind, manifestPath);
|
|
if (from === version) continue;
|
|
|
|
stale.push({ file, from });
|
|
if (check) continue;
|
|
|
|
// Textual surgery instead of re-serializing: JSON.stringify would refold
|
|
// arrays and fight prettier, turning a one-line version bump into
|
|
// formatting churn inside the release commit. The needle is built from
|
|
// the current pinned value, and anything other than exactly one
|
|
// occurrence aborts rather than guessing.
|
|
const currentNeedle = needle(from);
|
|
const occurrences = raw.split(currentNeedle).length - 1;
|
|
if (occurrences !== 1) {
|
|
throw new Error(
|
|
`Manifest surface ${manifestPath} has ${occurrences} occurrences of ${currentNeedle}; ` +
|
|
'expected exactly one, refusing to sync',
|
|
);
|
|
}
|
|
writeFileSync(manifestPath, raw.replace(currentNeedle, needle(version)));
|
|
synced.push(file);
|
|
}
|
|
|
|
return { version, synced, stale };
|
|
}
|
|
|
|
const invokedDirectly =
|
|
process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
|
|
if (invokedDirectly) {
|
|
const check = process.argv.includes('--check');
|
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const result = syncPluginManifests(rootDir, { check });
|
|
|
|
if (check && result.stale.length > 0) {
|
|
for (const { file, from } of result.stale) {
|
|
console.error(
|
|
`::error::${file} is at ${from} but gitnexus/package.json is at ${result.version}. ` +
|
|
'Run `node gitnexus/scripts/sync-plugin-manifests.mjs` and commit the result.',
|
|
);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
for (const file of result.synced) {
|
|
console.log(`synced ${file} -> ${result.version}`);
|
|
}
|
|
console.log(
|
|
result.stale.length === 0 && result.synced.length === 0
|
|
? `all plugin manifests already at ${result.version}`
|
|
: `plugin manifests now at ${result.version}`,
|
|
);
|
|
}
|