halfcycle 0.3.35 → 0.3.37
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/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -1
- package/bin/bin.bundle.mjs +471 -61
- package/dist/banner-facts.d.ts +6 -2
- package/dist/banner-facts.d.ts.map +1 -1
- package/dist/bin.js +435 -430
- package/dist/bin.js.map +3 -3
- package/dist/claude-version-probe.d.ts +44 -0
- package/dist/claude-version-probe.d.ts.map +1 -0
- package/dist/confirm-identity.d.ts +7 -0
- package/dist/confirm-identity.d.ts.map +1 -1
- package/dist/device-signin.d.ts +93 -17
- package/dist/device-signin.d.ts.map +1 -1
- package/dist/engagement-credential.d.ts +39 -27
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/index.js +189 -290
- package/dist/index.js.map +3 -3
- package/dist/install-manifest.d.ts +32 -4
- package/dist/install-manifest.d.ts.map +1 -1
- package/dist/install.d.ts +53 -23
- package/dist/install.d.ts.map +1 -1
- package/dist/loopback-signin.d.ts +8 -2
- package/dist/loopback-signin.d.ts.map +1 -1
- package/dist/open-phase.d.ts +2 -1
- package/dist/open-phase.d.ts.map +1 -1
- package/dist/probe-mcp.d.ts +39 -7
- package/dist/probe-mcp.d.ts.map +1 -1
- package/dist/uninstall.d.ts +1 -1
- package/dist/uninstall.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -27,6 +27,8 @@ var HALFCYCLE_DIR_NAME = ".halfcycle";
|
|
|
27
27
|
var ENGAGEMENTS_DIR_NAME = "engagements";
|
|
28
28
|
var ENGAGEMENT_ENV_FILENAME = "env";
|
|
29
29
|
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
30
|
+
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
31
|
+
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
30
32
|
var ENGAGEMENT_ID_PATTERN = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
|
|
31
33
|
var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
|
|
32
34
|
function isEngagementId(value) {
|
|
@@ -203,6 +205,10 @@ function looksLikeSecret(text) {
|
|
|
203
205
|
return false;
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
// ../core/dist/mcp-registration.js
|
|
209
|
+
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
210
|
+
var MCP_SERVER_KEY = "halfcycle";
|
|
211
|
+
|
|
206
212
|
// ../events/dist/result.js
|
|
207
213
|
import { z as z2 } from "zod";
|
|
208
214
|
var firedGuardSchema = z2.object({
|
|
@@ -999,6 +1005,19 @@ var ciTokenExchangeResponseSchema = z7.object({
|
|
|
999
1005
|
expiresAt: utcIso8601,
|
|
1000
1006
|
engagementId: z7.string()
|
|
1001
1007
|
}).strict();
|
|
1008
|
+
var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
|
|
1009
|
+
var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
|
|
1010
|
+
var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
|
|
1011
|
+
var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
|
|
1012
|
+
var NO_CREDENTIAL_SENTENCE = 'Halfcycle is not connected: this machine has no working Halfcycle credential for this project. Run "npx halfcycle" in the project folder to sign in again.';
|
|
1013
|
+
var NOT_THIS_ACCOUNT_SENTENCE = `Halfcycle is not connected: this project belongs to another Halfcycle account. To switch, run "npx halfcycle" in this project and ${SWITCH_ACCOUNT_ANSWER}.`;
|
|
1014
|
+
var UPDATE_CLAUDE_CODE_SENTENCE = "Halfcycle is not connected: this Claude Code version does not name the server it connects to, so no credential was sent. Update Claude Code, then reopen.";
|
|
1015
|
+
var NO_NODE_SENTENCE = "Halfcycle is not connected: Node is not installed, or not on the path this session uses, so nothing here is checked. Install Node, then reopen.";
|
|
1016
|
+
var PLACEHOLDER_SENTENCES = Object.freeze({
|
|
1017
|
+
[NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
|
|
1018
|
+
[NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
|
|
1019
|
+
[UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
|
|
1020
|
+
});
|
|
1002
1021
|
|
|
1003
1022
|
// ../events/dist/phase-record.js
|
|
1004
1023
|
import { z as z8 } from "zod";
|
|
@@ -1053,7 +1072,6 @@ function halfcycleHome(home) {
|
|
|
1053
1072
|
var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
|
|
1054
1073
|
var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
|
|
1055
1074
|
var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
|
|
1056
|
-
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
1057
1075
|
var InvalidEngagementIdError = class extends Error {
|
|
1058
1076
|
constructor(engagementId) {
|
|
1059
1077
|
const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
|
|
@@ -1352,6 +1370,15 @@ function bool(value, field) {
|
|
|
1352
1370
|
throw new UnreadableManifestError(`"${field}" is not true or false`);
|
|
1353
1371
|
return value;
|
|
1354
1372
|
}
|
|
1373
|
+
function appendedTextRecord(value, field) {
|
|
1374
|
+
if (!isRecord(value))
|
|
1375
|
+
throw new UnreadableManifestError(`"${field}" is not an object`);
|
|
1376
|
+
onlyKeys(value, ["created", "appended"], `"${field}"`);
|
|
1377
|
+
return {
|
|
1378
|
+
created: bool(value["created"], `${field}.created`),
|
|
1379
|
+
appended: stringArray(value["appended"], `${field}.appended`)
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1355
1382
|
function onlyKeys(value, allowed, where) {
|
|
1356
1383
|
const extra = Object.keys(value).filter((k) => !allowed.includes(k));
|
|
1357
1384
|
if (extra.length > 0)
|
|
@@ -1366,7 +1393,7 @@ function parseManifest(text) {
|
|
|
1366
1393
|
}
|
|
1367
1394
|
if (!isRecord(raw))
|
|
1368
1395
|
throw new UnreadableManifestError("it is not a JSON object");
|
|
1369
|
-
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
|
|
1396
|
+
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
|
|
1370
1397
|
if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
|
|
1371
1398
|
throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
|
|
1372
1399
|
}
|
|
@@ -1427,14 +1454,10 @@ function parseManifest(text) {
|
|
|
1427
1454
|
...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
|
|
1428
1455
|
};
|
|
1429
1456
|
}
|
|
1430
|
-
|
|
1431
|
-
if (!isRecord(g))
|
|
1457
|
+
if (raw["gitignore"] === void 0)
|
|
1432
1458
|
throw new UnreadableManifestError('"gitignore" is missing');
|
|
1433
|
-
|
|
1434
|
-
const
|
|
1435
|
-
created: bool(g["created"], "gitignore.created"),
|
|
1436
|
-
appended: stringArray(g["appended"], "gitignore.appended")
|
|
1437
|
-
};
|
|
1459
|
+
const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
|
|
1460
|
+
const gitattributes = raw["gitattributes"] === void 0 ? void 0 : appendedTextRecord(raw["gitattributes"], "gitattributes");
|
|
1438
1461
|
const paths = files.map((f) => f.path);
|
|
1439
1462
|
if (new Set(paths).size !== paths.length)
|
|
1440
1463
|
throw new UnreadableManifestError('"files" names a path twice');
|
|
@@ -1449,7 +1472,8 @@ function parseManifest(text) {
|
|
|
1449
1472
|
leftAlone,
|
|
1450
1473
|
settings,
|
|
1451
1474
|
...mcp !== void 0 ? { mcp } : {},
|
|
1452
|
-
gitignore
|
|
1475
|
+
gitignore,
|
|
1476
|
+
...gitattributes !== void 0 ? { gitattributes } : {}
|
|
1453
1477
|
};
|
|
1454
1478
|
}
|
|
1455
1479
|
function startManifest(previous, isMember, isMemberDir) {
|
|
@@ -1465,12 +1489,16 @@ function startManifest(previous, isMember, isMemberDir) {
|
|
|
1465
1489
|
createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
|
|
1466
1490
|
settings: previous?.settings,
|
|
1467
1491
|
mcp: previous?.mcp,
|
|
1468
|
-
gitignore: previous?.gitignore
|
|
1492
|
+
gitignore: previous?.gitignore,
|
|
1493
|
+
gitattributes: previous?.gitattributes
|
|
1469
1494
|
};
|
|
1470
1495
|
}
|
|
1471
1496
|
function recordInstalledFile(draft, path, bytes) {
|
|
1472
1497
|
draft.files.set(path, { path, sha256: sha256Hex(bytes) });
|
|
1473
1498
|
}
|
|
1499
|
+
function recordRemovedFile(draft, path) {
|
|
1500
|
+
draft.files.delete(path);
|
|
1501
|
+
}
|
|
1474
1502
|
function recordPerMachineFile(draft, path) {
|
|
1475
1503
|
draft.files.set(path, { path, perMachine: true });
|
|
1476
1504
|
}
|
|
@@ -1513,8 +1541,15 @@ function recordMcp(draft, observed) {
|
|
|
1513
1541
|
};
|
|
1514
1542
|
}
|
|
1515
1543
|
function recordGitignore(draft, created, appended) {
|
|
1516
|
-
|
|
1517
|
-
|
|
1544
|
+
draft.gitignore = carryAppended(draft.gitignore, created, appended);
|
|
1545
|
+
}
|
|
1546
|
+
function recordGitattributes(draft, created, appended) {
|
|
1547
|
+
if (draft.gitattributes === void 0 && !created && appended.every((a) => a === ""))
|
|
1548
|
+
return;
|
|
1549
|
+
draft.gitattributes = carryAppended(draft.gitattributes, created, appended);
|
|
1550
|
+
}
|
|
1551
|
+
function carryAppended(prev, created, appended) {
|
|
1552
|
+
return {
|
|
1518
1553
|
created: prev?.created ?? created,
|
|
1519
1554
|
appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
|
|
1520
1555
|
};
|
|
@@ -1538,7 +1573,8 @@ function finishManifest(draft) {
|
|
|
1538
1573
|
denyCreated: false
|
|
1539
1574
|
},
|
|
1540
1575
|
...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
|
|
1541
|
-
gitignore: draft.gitignore ?? { created: false, appended: [] }
|
|
1576
|
+
gitignore: draft.gitignore ?? { created: false, appended: [] },
|
|
1577
|
+
...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
|
|
1542
1578
|
};
|
|
1543
1579
|
}
|
|
1544
1580
|
function union(a, b) {
|
|
@@ -1697,11 +1733,12 @@ var WRITE_ALLOWLIST = [
|
|
|
1697
1733
|
".claude/hooks",
|
|
1698
1734
|
".claude/settings.json",
|
|
1699
1735
|
".halfcycle",
|
|
1700
|
-
|
|
1736
|
+
BUNDLE_PIN_REL,
|
|
1701
1737
|
".halfcycle/bin",
|
|
1702
1738
|
"docs/build-records",
|
|
1703
1739
|
"test/fixtures/captured",
|
|
1704
|
-
|
|
1740
|
+
MCP_REGISTRATION_REL,
|
|
1741
|
+
".gitattributes",
|
|
1705
1742
|
ZONE_B_DIR
|
|
1706
1743
|
];
|
|
1707
1744
|
function isAllowlisted(targetRelPath) {
|
|
@@ -1711,15 +1748,16 @@ function isAllowlisted(targetRelPath) {
|
|
|
1711
1748
|
return normalised === p || normalised.startsWith(p + "/");
|
|
1712
1749
|
});
|
|
1713
1750
|
}
|
|
1714
|
-
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
1715
1751
|
var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
|
|
1716
1752
|
var CREW_ROSTER_REL = ".halfcycle/crew.json";
|
|
1717
1753
|
var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
|
|
1718
1754
|
var GITIGNORE_REL = ".gitignore";
|
|
1755
|
+
var GITATTRIBUTES_REL = ".gitattributes";
|
|
1719
1756
|
function commandStubRel(name) {
|
|
1720
1757
|
return `.claude/commands/${name}.md`;
|
|
1721
1758
|
}
|
|
1722
|
-
var
|
|
1759
|
+
var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
1760
|
+
var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
|
|
1723
1761
|
var RETIRED_DENY_PATTERNS = [];
|
|
1724
1762
|
function closedWriteSet() {
|
|
1725
1763
|
const files = /* @__PURE__ */ new Set([
|
|
@@ -1734,7 +1772,7 @@ function closedWriteSet() {
|
|
|
1734
1772
|
INSTALL_MANIFEST_REL,
|
|
1735
1773
|
...RETIRED_WRITE_SET_FILES
|
|
1736
1774
|
]);
|
|
1737
|
-
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
|
|
1775
|
+
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
|
|
1738
1776
|
const dirs = /* @__PURE__ */ new Set();
|
|
1739
1777
|
for (const path of [...files, ...merged]) {
|
|
1740
1778
|
for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
|
|
@@ -1748,7 +1786,11 @@ function closedWriteSet() {
|
|
|
1748
1786
|
ownedDirs,
|
|
1749
1787
|
hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
|
|
1750
1788
|
denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
|
|
1751
|
-
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
|
|
1789
|
+
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
|
|
1790
|
+
gitattributesLines: /* @__PURE__ */ new Set([
|
|
1791
|
+
GITATTRIBUTES_HEADER,
|
|
1792
|
+
...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
|
|
1793
|
+
])
|
|
1752
1794
|
};
|
|
1753
1795
|
}
|
|
1754
1796
|
function readPreviousManifest(targetRepo) {
|
|
@@ -1812,15 +1854,19 @@ function recordMcpAdoptionOnly(draft, existingText) {
|
|
|
1812
1854
|
}
|
|
1813
1855
|
function recordGitignoreReconcile(draft, before, after) {
|
|
1814
1856
|
const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
|
|
1815
|
-
|
|
1816
|
-
if (after !== null && after !== before) {
|
|
1817
|
-
if (before === null)
|
|
1818
|
-
appended = after;
|
|
1819
|
-
else if (after.startsWith(before))
|
|
1820
|
-
appended = after.slice(before.length);
|
|
1821
|
-
}
|
|
1857
|
+
const appended = appendedText(before, after);
|
|
1822
1858
|
recordGitignore(draft, before === null && after !== null, [adopted, appended]);
|
|
1823
1859
|
}
|
|
1860
|
+
function recordGitattributesReconcile(draft, before, after) {
|
|
1861
|
+
recordGitattributes(draft, before === null && after !== null, [appendedText(before, after)]);
|
|
1862
|
+
}
|
|
1863
|
+
function appendedText(before, after) {
|
|
1864
|
+
if (after === null || after === before)
|
|
1865
|
+
return "";
|
|
1866
|
+
if (before === null)
|
|
1867
|
+
return after;
|
|
1868
|
+
return after.startsWith(before) ? after.slice(before.length) : "";
|
|
1869
|
+
}
|
|
1824
1870
|
function assertManifestInWriteSet(manifest, writeSet) {
|
|
1825
1871
|
const strays = [
|
|
1826
1872
|
...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
|
|
@@ -1835,7 +1881,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
|
|
|
1835
1881
|
const merged = [
|
|
1836
1882
|
SETTINGS_REL,
|
|
1837
1883
|
...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
|
|
1838
|
-
...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
|
|
1884
|
+
...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
|
|
1885
|
+
...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
|
|
1839
1886
|
];
|
|
1840
1887
|
const collided = new Set(collidedPaths);
|
|
1841
1888
|
const dirVerdicts = /* @__PURE__ */ new Map();
|
|
@@ -2063,6 +2110,20 @@ exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
|
2063
2110
|
}
|
|
2064
2111
|
var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
|
|
2065
2112
|
var SESSION_ID_FROM_HOOK_INPUT_SH = `SESSION_ID="$(printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(typeof j.session_id==="string"?j.session_id:"")}catch{process.stdout.write("")}})' 2>/dev/null || true)"`;
|
|
2113
|
+
var REPO_HASH_FROM_ROOT_SH = `HASH="$(printf '%s' "$REPO_ROOT" | node -e 'const c=[];process.stdin.on("data",d=>c.push(d)).on("end",()=>{process.stdout.write(require("node:crypto").createHash("sha256").update(Buffer.concat(c)).digest("hex").slice(0,12))})' 2>/dev/null || true)"`;
|
|
2114
|
+
function noNodeReplyShell() {
|
|
2115
|
+
const reply = JSON.stringify({
|
|
2116
|
+
systemMessage: NO_NODE_SENTENCE,
|
|
2117
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: NO_NODE_SENTENCE }
|
|
2118
|
+
});
|
|
2119
|
+
if (reply.includes("'")) {
|
|
2120
|
+
throw new Error("[bundle install] the no-Node notice cannot be single-quoted; refusing to write the session-start script.");
|
|
2121
|
+
}
|
|
2122
|
+
return `if ! command -v node >/dev/null 2>&1; then
|
|
2123
|
+
printf '%s\\n' '${reply}'
|
|
2124
|
+
exit 0
|
|
2125
|
+
fi`;
|
|
2126
|
+
}
|
|
2066
2127
|
function generateSessionStartMarker() {
|
|
2067
2128
|
return `#!/usr/bin/env bash
|
|
2068
2129
|
${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
@@ -2071,10 +2132,15 @@ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
|
2071
2132
|
# it began instead of only against the latest commit. The session id comes in on
|
|
2072
2133
|
# stdin with the hook input; without one, the older session-less name is written
|
|
2073
2134
|
# and the stop-time check compares against the latest commit as it used to.
|
|
2135
|
+
#
|
|
2136
|
+
# Without Node nothing Halfcycle installs here can run, so this says so once and
|
|
2137
|
+
# writes no marker.
|
|
2138
|
+
${noNodeReplyShell()}
|
|
2074
2139
|
INPUT="$(cat 2>/dev/null || true)"
|
|
2075
2140
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
2076
2141
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
2077
|
-
|
|
2142
|
+
${REPO_HASH_FROM_ROOT_SH}
|
|
2143
|
+
if [ -z "$HASH" ]; then exit 0; fi
|
|
2078
2144
|
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
2079
2145
|
MARKER_DIR="\${TMPDIR:-/tmp}"
|
|
2080
2146
|
MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
|
|
@@ -2084,64 +2150,6 @@ else
|
|
|
2084
2150
|
MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
|
|
2085
2151
|
fi
|
|
2086
2152
|
git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
|
|
2087
|
-
|
|
2088
|
-
# ---------------------------------------------------------------------------
|
|
2089
|
-
# STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
|
|
2090
|
-
#
|
|
2091
|
-
# A repository with no credential on this machine is UNGUARDED: the PostToolUse
|
|
2092
|
-
# hook will find nothing to evaluate with, and it says so once per edit, after
|
|
2093
|
-
# the edit. This says it once, up front, before anything is written \u2014 which is
|
|
2094
|
-
# the one thing a session-start hook can do that nothing else can.
|
|
2095
|
-
#
|
|
2096
|
-
# TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
|
|
2097
|
-
# solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
|
|
2098
|
-
# fixed by re-running the installer; "this is not your project" is permanent,
|
|
2099
|
-
# and the same remedy would send the reader in a circle forever. The two are
|
|
2100
|
-
# told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
|
|
2101
|
-
# below from a machine-level marker beside the credential store.
|
|
2102
|
-
#
|
|
2103
|
-
# Everything below runs in a SUBSHELL: it sources a credential store, and a
|
|
2104
|
-
# session-start hook must not leak an engagement's variables into whatever the
|
|
2105
|
-
# editor runs next. Any failure inside it is swallowed; this script's exit
|
|
2106
|
-
# status is 0 either way, and the marker above has already been written.
|
|
2107
|
-
# ---------------------------------------------------------------------------
|
|
2108
|
-
${engagementResolutionShell()}
|
|
2109
|
-
|
|
2110
|
-
(
|
|
2111
|
-
if halfcycle_env_file "$REPO_ROOT"; then
|
|
2112
|
-
set -a
|
|
2113
|
-
. "$HALFCYCLE_ENV_FILE"
|
|
2114
|
-
set +a
|
|
2115
|
-
fi
|
|
2116
|
-
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
2117
|
-
# OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
|
|
2118
|
-
# case below wearing different words: there is nothing to fix, and the loop
|
|
2119
|
-
# that follows is skipped entirely rather than run and then overridden \u2014 a
|
|
2120
|
-
# repository whose credential is absent for THIS reason must never be told
|
|
2121
|
-
# to re-run the installer, which can never obtain one for it.
|
|
2122
|
-
echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
|
|
2123
|
-
echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
|
|
2124
|
-
echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
|
|
2125
|
-
echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
|
|
2126
|
-
echo "[halfcycle] Ask the project's owner for view access."
|
|
2127
|
-
echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
|
|
2128
|
-
echo "[halfcycle] \\"npx halfcycle\\" again."
|
|
2129
|
-
else
|
|
2130
|
-
hc_missing=""
|
|
2131
|
-
for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
|
|
2132
|
-
eval "hc_value=\\\${$hc_key:-}"
|
|
2133
|
-
if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
|
|
2134
|
-
done
|
|
2135
|
-
if [ -n "$hc_missing" ]; then
|
|
2136
|
-
echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
|
|
2137
|
-
echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
|
|
2138
|
-
echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
|
|
2139
|
-
echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
|
|
2140
|
-
echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
|
|
2141
|
-
echo "[halfcycle] and needs nothing configured first."
|
|
2142
|
-
fi
|
|
2143
|
-
fi
|
|
2144
|
-
) 2>/dev/null || true
|
|
2145
2153
|
exit 0
|
|
2146
2154
|
`;
|
|
2147
2155
|
}
|
|
@@ -2157,7 +2165,8 @@ INPUT="$(cat 2>/dev/null || true)"
|
|
|
2157
2165
|
|
|
2158
2166
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
2159
2167
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
2160
|
-
|
|
2168
|
+
${REPO_HASH_FROM_ROOT_SH}
|
|
2169
|
+
if [ -z "$HASH" ]; then exit 0; fi
|
|
2161
2170
|
|
|
2162
2171
|
# The session id arrives on stdin as part of the hook payload. Empty when absent.
|
|
2163
2172
|
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
@@ -2204,7 +2213,8 @@ set -e
|
|
|
2204
2213
|
|
|
2205
2214
|
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
2206
2215
|
# session's cwd \u2014 a session started in a subdirectory must find the same root
|
|
2207
|
-
# .mcp.json the editor loaded, which is the bug
|
|
2216
|
+
# .mcp.json the editor loaded, which is the bug the editor's headers command
|
|
2217
|
+
# already paid for.
|
|
2208
2218
|
HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
2209
2219
|
HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
|
|
2210
2220
|
HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
|
|
@@ -2251,7 +2261,9 @@ function isTrackedAndClean(targetRepo, relPath) {
|
|
|
2251
2261
|
return true;
|
|
2252
2262
|
}
|
|
2253
2263
|
function removeLegacyCiStanza(targetRepo) {
|
|
2254
|
-
|
|
2264
|
+
return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
|
|
2265
|
+
}
|
|
2266
|
+
function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
|
|
2255
2267
|
const path = join5(targetRepo, rel);
|
|
2256
2268
|
if (!existsSync3(path))
|
|
2257
2269
|
return "absent";
|
|
@@ -2261,10 +2273,9 @@ function removeLegacyCiStanza(targetRepo) {
|
|
|
2261
2273
|
} catch {
|
|
2262
2274
|
return "failed";
|
|
2263
2275
|
}
|
|
2264
|
-
const headerLine = raw.split("\n")[
|
|
2276
|
+
const headerLine = raw.split("\n")[headerLineIndex] ?? "";
|
|
2265
2277
|
const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
|
|
2266
|
-
|
|
2267
|
-
if (!ours)
|
|
2278
|
+
if (!knownHeaderHashes.includes(digest))
|
|
2268
2279
|
return "kept-foreign";
|
|
2269
2280
|
if (!isTrackedAndClean(targetRepo, rel))
|
|
2270
2281
|
return "kept-uncommitted";
|
|
@@ -2275,205 +2286,33 @@ function removeLegacyCiStanza(targetRepo) {
|
|
|
2275
2286
|
}
|
|
2276
2287
|
return "removed";
|
|
2277
2288
|
}
|
|
2278
|
-
var
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2289
|
+
var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
|
|
2290
|
+
`const p=require('path'),f=require('fs'),c=require('child_process');`,
|
|
2291
|
+
`let d=process.cwd();`,
|
|
2292
|
+
`for(;;){`,
|
|
2293
|
+
`const b=p.join(d,'${VENDORED_BIN_REL}');`,
|
|
2294
|
+
`if(f.existsSync(b)){`,
|
|
2295
|
+
`const r=c.spawnSync(process.execPath,[b,'mcp-headers'],{stdio:'inherit'});`,
|
|
2296
|
+
`process.exit(typeof r.status==='number'?r.status:1)}`,
|
|
2297
|
+
`const u=p.dirname(d);`,
|
|
2298
|
+
`if(u===d)break;`,
|
|
2299
|
+
`d=u}`,
|
|
2300
|
+
`console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
|
|
2301
|
+
`process.exit(1)`
|
|
2302
|
+
].join("")}"`;
|
|
2303
|
+
var MCP_HEADERS_KNOWN_HEADER_HASHES = [
|
|
2304
|
+
"37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
|
|
2305
|
+
"6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
|
|
2306
|
+
];
|
|
2307
|
+
function removeRetiredMcpHeadersHelper(targetRepo) {
|
|
2308
|
+
return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
|
|
2309
|
+
}
|
|
2283
2310
|
var OWNED_GENERATED_HEADERS = {
|
|
2284
2311
|
".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
|
|
2285
2312
|
".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
|
|
2286
2313
|
".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
|
|
2287
|
-
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
|
|
2288
|
-
".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
|
|
2314
|
+
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
|
|
2289
2315
|
};
|
|
2290
|
-
function generateMcpHeadersHelper() {
|
|
2291
|
-
return `#!/bin/sh
|
|
2292
|
-
${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
|
|
2293
|
-
#
|
|
2294
|
-
# Claude Code runs this at MCP connection time, from the session's cwd, and merges
|
|
2295
|
-
# its stdout into the connection headers for the "halfcycle" server in .mcp.json.
|
|
2296
|
-
#
|
|
2297
|
-
# It exists because \${VAR} expansion in .mcp.json reads the PROCESS environment
|
|
2298
|
-
# only: nothing loads a credential into a session, so a referenced
|
|
2299
|
-
# \${HALFCYCLE_TOKEN} expanded to nothing and the server was silently absent from
|
|
2300
|
-
# the tool list.
|
|
2301
|
-
#
|
|
2302
|
-
# This file is tracked and holds NO secret. NEITHER DOES ANY OTHER FILE IN THIS
|
|
2303
|
-
# REPOSITORY (T-09): the credential lives at machine level, outside every checkout,
|
|
2304
|
-
# and this script READS one line out of it (never sources \u2014 a credential store is
|
|
2305
|
-
# not code).
|
|
2306
|
-
set -eu
|
|
2307
|
-
|
|
2308
|
-
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
2309
|
-
# caller's working directory. Claude Code runs the helper from the SESSION's cwd,
|
|
2310
|
-
# and a session started in a subdirectory still finds the root .mcp.json \u2014 so a
|
|
2311
|
-
# relative read here silently produced no credential for anyone who started the
|
|
2312
|
-
# session in any subdirectory of a monorepo.
|
|
2313
|
-
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
2314
|
-
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
|
2315
|
-
|
|
2316
|
-
${engagementResolutionShell()}
|
|
2317
|
-
|
|
2318
|
-
# Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
|
|
2319
|
-
# shell (call it as a statement, never inside $(...)). Last assignment wins,
|
|
2320
|
-
# matching the shell's own \`.\` semantics. An optional leading \`export \` is
|
|
2321
|
-
# tolerated because a hand-edited store may use one.
|
|
2322
|
-
halfcycle_store_value() {
|
|
2323
|
-
HC_VALUE=""
|
|
2324
|
-
hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
|
|
2325
|
-
hc_v=\${hc_line#*"$2="}
|
|
2326
|
-
hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
|
|
2327
|
-
|
|
2328
|
-
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
2329
|
-
# writers use the same shq: this installer and Studio's provider), because
|
|
2330
|
-
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
2331
|
-
# would execute its own remainder. This reader greps rather than sources, so it
|
|
2332
|
-
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
2333
|
-
# sourcing consumer gets, or one file has two answers.
|
|
2334
|
-
case $hc_v in
|
|
2335
|
-
'"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
|
|
2336
|
-
"'"*"'")
|
|
2337
|
-
hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
|
|
2338
|
-
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
2339
|
-
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
2340
|
-
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
2341
|
-
# no shell quoting is in the way:
|
|
2342
|
-
#
|
|
2343
|
-
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
2344
|
-
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
2345
|
-
#
|
|
2346
|
-
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
2347
|
-
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
2348
|
-
# and the token then travels with four stray characters in it. The bracket
|
|
2349
|
-
# expression makes the backslash literal by a construction POSIX does define.
|
|
2350
|
-
hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
|
|
2351
|
-
esac
|
|
2352
|
-
HC_VALUE=$hc_v
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
# The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
|
|
2356
|
-
# a space or a control character anywhere. That refusal is the load-bearing part:
|
|
2357
|
-
# a URL parser drops tabs and newlines before it reads the host, so
|
|
2358
|
-
# "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
|
|
2359
|
-
# line-by-line reading here would see only "https://ours". The host ends at the
|
|
2360
|
-
# first / ? # or backslash, which is where a URL parser ends it for http(s).
|
|
2361
|
-
halfcycle_origin() {
|
|
2362
|
-
hc_u=$1
|
|
2363
|
-
if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
|
|
2364
|
-
return 0
|
|
2365
|
-
fi
|
|
2366
|
-
printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
# HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
|
|
2370
|
-
# browser and no per-user store exports the credential, and a stale store on a
|
|
2371
|
-
# long-lived runner must not silently win over it. Same order, and the same reason,
|
|
2372
|
-
# as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
|
|
2373
|
-
# to is NOT taken from the environment: it is read from this machine's store in
|
|
2374
|
-
# both arms, so a token supplied this way still goes only where this machine was
|
|
2375
|
-
# set up to send it.
|
|
2376
|
-
# The pin names an id that is not a project id. Re-running the installer against
|
|
2377
|
-
# that pin refuses it too, so the one remedy that works is a fresh pin.
|
|
2378
|
-
halfcycle_bad_id_message() {
|
|
2379
|
-
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
|
|
2380
|
-
echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
|
|
2381
|
-
echo "halfcycle: is then set up as a new Halfcycle project." >&2
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
TOKEN=\${HALFCYCLE_TOKEN:-}
|
|
2385
|
-
MCP_URL=""
|
|
2386
|
-
|
|
2387
|
-
if [ -n "$TOKEN" ]; then
|
|
2388
|
-
if halfcycle_env_file "$PROJECT_ROOT"; then
|
|
2389
|
-
halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
|
|
2390
|
-
MCP_URL=$HC_VALUE
|
|
2391
|
-
fi
|
|
2392
|
-
else
|
|
2393
|
-
if ! halfcycle_env_file "$PROJECT_ROOT"; then
|
|
2394
|
-
case $HALFCYCLE_ENV_PROBLEM in
|
|
2395
|
-
no-pin)
|
|
2396
|
-
echo "halfcycle: no .halfcycle/bundle.json in $PROJECT_ROOT, so this repository has no Halfcycle engagement." >&2
|
|
2397
|
-
echo "halfcycle: run \\"npx halfcycle\\" here to install one." >&2 ;;
|
|
2398
|
-
no-id)
|
|
2399
|
-
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
|
|
2400
|
-
echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
|
|
2401
|
-
bad-id)
|
|
2402
|
-
halfcycle_bad_id_message ;;
|
|
2403
|
-
no-home)
|
|
2404
|
-
echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
|
|
2405
|
-
*)
|
|
2406
|
-
# OUTCOME B (T-02b, solo-project-ownership) is a \`no-credential\` PROBLEM
|
|
2407
|
-
# with a REASON attached, checked here rather than as a fifth case value
|
|
2408
|
-
# \u2014 see \`engagementResolutionShell\`'s own docblock for why. Re-running
|
|
2409
|
-
# this installer can never obtain a credential for this reason, so it is
|
|
2410
|
-
# the one sentence in this file that must not say to.
|
|
2411
|
-
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
2412
|
-
echo "halfcycle: this project belongs to another Halfcycle account, so this machine has no" >&2
|
|
2413
|
-
echo "halfcycle: credential for it and the Halfcycle tools are not available here. Nothing is" >&2
|
|
2414
|
-
echo "halfcycle: broken \u2014 ask the project's owner for view access, or sign in as the account" >&2
|
|
2415
|
-
echo "halfcycle: that owns it and run \\"npx halfcycle\\" again." >&2
|
|
2416
|
-
else
|
|
2417
|
-
echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
|
|
2418
|
-
echo "halfcycle: run \\"npx halfcycle\\" in this repository \u2014 it will sign you in through your browser if needed, and write the credential to $HOME/.halfcycle, outside this repository." >&2
|
|
2419
|
-
fi ;;
|
|
2420
|
-
esac
|
|
2421
|
-
exit 1
|
|
2422
|
-
fi
|
|
2423
|
-
ENV_FILE="$HALFCYCLE_ENV_FILE"
|
|
2424
|
-
|
|
2425
|
-
halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
|
|
2426
|
-
TOKEN=$HC_VALUE
|
|
2427
|
-
if [ -z "$TOKEN" ]; then
|
|
2428
|
-
echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
|
|
2429
|
-
echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
|
|
2430
|
-
exit 1
|
|
2431
|
-
fi
|
|
2432
|
-
halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
|
|
2433
|
-
MCP_URL=$HC_VALUE
|
|
2434
|
-
fi
|
|
2435
|
-
|
|
2436
|
-
# THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
|
|
2437
|
-
# file: anyone who can land a commit can change the server's url, and this script
|
|
2438
|
-
# would then hand the credential to whatever it names. Claude Code tells the helper
|
|
2439
|
-
# which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
|
|
2440
|
-
# machine was set up against is recorded OUTSIDE the repository, beside the
|
|
2441
|
-
# credential. The two must share scheme, host and port, or nothing is printed.
|
|
2442
|
-
# No url at all is a refusal too: sending the credential without knowing where it
|
|
2443
|
-
# is going is the thing this check exists to stop.
|
|
2444
|
-
REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
|
|
2445
|
-
if [ -z "$REQUESTED" ]; then
|
|
2446
|
-
echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
|
|
2447
|
-
echo "halfcycle: update Claude Code, then reconnect." >&2
|
|
2448
|
-
exit 1
|
|
2449
|
-
fi
|
|
2450
|
-
if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
|
|
2451
|
-
halfcycle_bad_id_message
|
|
2452
|
-
exit 1
|
|
2453
|
-
fi
|
|
2454
|
-
if [ -z "$MCP_URL" ]; then
|
|
2455
|
-
echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
|
|
2456
|
-
echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
|
|
2457
|
-
exit 1
|
|
2458
|
-
fi
|
|
2459
|
-
WANT=$(halfcycle_origin "$MCP_URL")
|
|
2460
|
-
GOT=$(halfcycle_origin "$REQUESTED")
|
|
2461
|
-
if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
|
|
2462
|
-
SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
|
|
2463
|
-
echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
|
|
2464
|
-
echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
|
|
2465
|
-
echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
|
|
2466
|
-
exit 1
|
|
2467
|
-
fi
|
|
2468
|
-
|
|
2469
|
-
# JSON-escape: backslash first, then double quote. A token carrying either would
|
|
2470
|
-
# otherwise emit invalid JSON, and invalid JSON is dropped SILENTLY \u2014 the
|
|
2471
|
-
# connection succeeds carrying no Authorization header at all.
|
|
2472
|
-
ESCAPED=$(printf '%s' "$TOKEN" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
|
|
2473
|
-
|
|
2474
|
-
printf '{"Authorization":"Bearer %s"}\\n' "$ESCAPED"
|
|
2475
|
-
`;
|
|
2476
|
-
}
|
|
2477
2316
|
function generateMcpRegistration(existing, mcpOrigin) {
|
|
2478
2317
|
let base = { mcpServers: {} };
|
|
2479
2318
|
if (existing !== null) {
|
|
@@ -2540,6 +2379,42 @@ ${header}
|
|
|
2540
2379
|
return "failed";
|
|
2541
2380
|
}
|
|
2542
2381
|
}
|
|
2382
|
+
var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
|
|
2383
|
+
function readTextOrNull(path) {
|
|
2384
|
+
try {
|
|
2385
|
+
return readFileSync4(path, "utf-8");
|
|
2386
|
+
} catch {
|
|
2387
|
+
return null;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
function lfRules(paths) {
|
|
2391
|
+
return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
|
|
2392
|
+
}
|
|
2393
|
+
var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
|
|
2394
|
+
function reconcileGitattributes(targetRepo) {
|
|
2395
|
+
const path = join5(targetRepo, GITATTRIBUTES_REL);
|
|
2396
|
+
const rules = REQUIRED_GITATTRIBUTES_RULES;
|
|
2397
|
+
const routed = [];
|
|
2398
|
+
try {
|
|
2399
|
+
if (lstatOrNull(path)?.isSymbolicLink() === true)
|
|
2400
|
+
return "failed";
|
|
2401
|
+
if (!existsSync3(path)) {
|
|
2402
|
+
writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
|
|
2403
|
+
return "written";
|
|
2404
|
+
}
|
|
2405
|
+
const existing = readFileSync4(path, "utf-8");
|
|
2406
|
+
const present = new Set(existing.split("\n").map((l) => l.trim()));
|
|
2407
|
+
const toAdd = rules.filter((rule) => !present.has(rule));
|
|
2408
|
+
if (toAdd.length === 0)
|
|
2409
|
+
return "skipped";
|
|
2410
|
+
const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
2411
|
+
const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
|
|
2412
|
+
writeAllowlisted(path, targetRepo, existing + suffix, routed);
|
|
2413
|
+
return "written";
|
|
2414
|
+
} catch {
|
|
2415
|
+
return "failed";
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2543
2418
|
function previousPinForEngagement(targetRepoRoot, engagementId) {
|
|
2544
2419
|
try {
|
|
2545
2420
|
const existing = readBundlePin(targetRepoRoot);
|
|
@@ -2752,9 +2627,6 @@ async function install(options) {
|
|
|
2752
2627
|
break;
|
|
2753
2628
|
}
|
|
2754
2629
|
if (credential) {
|
|
2755
|
-
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
2756
|
-
const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
|
|
2757
|
-
noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
|
|
2758
2630
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
2759
2631
|
const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
|
|
2760
2632
|
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
@@ -2766,6 +2638,24 @@ async function install(options) {
|
|
|
2766
2638
|
} else {
|
|
2767
2639
|
report.skippedPaths.push(MCP_REGISTRATION_REL);
|
|
2768
2640
|
}
|
|
2641
|
+
const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
|
|
2642
|
+
switch (removeRetiredMcpHeadersHelper(targetRepo)) {
|
|
2643
|
+
case "removed":
|
|
2644
|
+
recordRemovedFile(draft, helperRel);
|
|
2645
|
+
report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
|
|
2646
|
+
break;
|
|
2647
|
+
case "kept-foreign":
|
|
2648
|
+
report.skippedPaths.push(`${helperRel} (KEPT \u2014 Halfcycle no longer uses this file, and it is not one Halfcycle generated byte for byte, so it was left alone; delete it if nothing of yours needs it)`);
|
|
2649
|
+
break;
|
|
2650
|
+
case "kept-uncommitted":
|
|
2651
|
+
report.skippedPaths.push(`${helperRel} (KEPT \u2014 Halfcycle no longer uses this file, but it is not a clean, committed copy in this repository, so it was left alone rather than risk losing an edit; delete it when you are ready)`);
|
|
2652
|
+
break;
|
|
2653
|
+
case "failed":
|
|
2654
|
+
report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
|
|
2655
|
+
break;
|
|
2656
|
+
case "absent":
|
|
2657
|
+
break;
|
|
2658
|
+
}
|
|
2769
2659
|
} else {
|
|
2770
2660
|
report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
|
|
2771
2661
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
@@ -2781,6 +2671,15 @@ async function install(options) {
|
|
|
2781
2671
|
} else {
|
|
2782
2672
|
record(report, gitignoreOutcome, ".gitignore");
|
|
2783
2673
|
}
|
|
2674
|
+
const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
|
|
2675
|
+
const gitattributesBefore = readTextOrNull(gitattributesPath);
|
|
2676
|
+
const gitattributesOutcome = reconcileGitattributes(targetRepo);
|
|
2677
|
+
recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
|
|
2678
|
+
if (gitattributesOutcome === "failed") {
|
|
2679
|
+
report.skippedPaths.push(`${GITATTRIBUTES_REL} (write failed \u2014 a clone made with core.autocrlf=true may check the hook scripts out with CRLF line endings)`);
|
|
2680
|
+
} else {
|
|
2681
|
+
record(report, gitattributesOutcome, GITATTRIBUTES_REL);
|
|
2682
|
+
}
|
|
2784
2683
|
const identityOutcome = writeProjectIdentity(targetRepo);
|
|
2785
2684
|
record(report, identityOutcome, PROJECT_IDENTITY_REL);
|
|
2786
2685
|
noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
|