halfcycle 0.3.26 → 0.3.27
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 +4 -6
- package/bin/bin.bundle.mjs +106 -53
- package/dist/bin.d.ts +11 -5
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +1349 -347
- package/dist/bin.js.map +3 -3
- package/dist/cli-contract.d.ts +1 -1
- package/dist/cli-contract.d.ts.map +1 -1
- package/dist/create-engagement.d.ts +5 -5
- package/dist/device-signin.d.ts +7 -4
- package/dist/device-signin.d.ts.map +1 -1
- package/dist/engagement-credential.d.ts +50 -4
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/index.js +673 -163
- package/dist/index.js.map +3 -3
- package/dist/install-manifest.d.ts +169 -0
- package/dist/install-manifest.d.ts.map +1 -0
- package/dist/install.d.ts +146 -7
- package/dist/install.d.ts.map +1 -1
- package/dist/merge-settings.d.ts +12 -0
- package/dist/merge-settings.d.ts.map +1 -1
- package/dist/open-phase.d.ts.map +1 -1
- package/dist/scan.d.ts +7 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/uninstall.d.ts +96 -0
- package/dist/uninstall.d.ts.map +1 -0
- package/package.json +2 -2
- package/dist/ci-bind.d.ts +0 -116
- package/dist/ci-bind.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// dist/install.js
|
|
2
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
3
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2
4
|
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3
|
-
import { dirname as dirname2, join as join5, relative } from "node:path";
|
|
5
|
+
import { dirname as dirname2, join as join5, posix, relative } from "node:path";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
|
|
6
8
|
// ../events/dist/result.js
|
|
@@ -28,6 +30,11 @@ var HALFCYCLE_DIR_NAME = ".halfcycle";
|
|
|
28
30
|
var ENGAGEMENTS_DIR_NAME = "engagements";
|
|
29
31
|
var ENGAGEMENT_ENV_FILENAME = "env";
|
|
30
32
|
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
33
|
+
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}$";
|
|
34
|
+
var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
|
|
35
|
+
function isEngagementId(value) {
|
|
36
|
+
return typeof value === "string" && ENGAGEMENT_ID_SHAPE.test(value);
|
|
37
|
+
}
|
|
31
38
|
var ENGAGEMENT_ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
|
|
32
39
|
function shq(value) {
|
|
33
40
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -675,7 +682,19 @@ var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
|
|
|
675
682
|
var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
|
|
676
683
|
var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
|
|
677
684
|
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
685
|
+
var InvalidEngagementIdError = class extends Error {
|
|
686
|
+
constructor(engagementId) {
|
|
687
|
+
const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
|
|
688
|
+
super(`${shown} is not a Halfcycle project id, so it cannot name a folder on this machine and nothing was written. A project id looks like 3f2a1c4e-8b7d-4e2f-9a61-0c5d7e8f9b10. If it is the "${PIN_ENGAGEMENT_ID_FIELD}" in .halfcycle/bundle.json, delete that file and run "npx halfcycle" again: this repository is then set up as a new Halfcycle project.`);
|
|
689
|
+
this.name = "InvalidEngagementIdError";
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
function assertEngagementId(engagementId) {
|
|
693
|
+
if (!isEngagementId(engagementId))
|
|
694
|
+
throw new InvalidEngagementIdError(engagementId);
|
|
695
|
+
}
|
|
678
696
|
function engagementStateDir(engagementId, home) {
|
|
697
|
+
assertEngagementId(engagementId);
|
|
679
698
|
return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
|
|
680
699
|
}
|
|
681
700
|
function engagementEnvPath(engagementId, home) {
|
|
@@ -766,6 +785,14 @@ halfcycle_env_file() {
|
|
|
766
785
|
HALFCYCLE_ENV_PROBLEM="no-id"
|
|
767
786
|
return 1
|
|
768
787
|
fi
|
|
788
|
+
# The id is about to become a path under $HOME, and it came from a committed
|
|
789
|
+
# file: anything that is not a project id (a "../", a "/") is refused here,
|
|
790
|
+
# before the path exists. hc_id holds no newline (the pin was flattened above),
|
|
791
|
+
# so grep sees exactly one line.
|
|
792
|
+
if ! printf '%s\\n' "$hc_id" | grep -Eq '${ENGAGEMENT_ID_PATTERN}'; then
|
|
793
|
+
HALFCYCLE_ENV_PROBLEM="bad-id"
|
|
794
|
+
return 1
|
|
795
|
+
fi
|
|
769
796
|
HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
|
|
770
797
|
if [ -z "\${HOME:-}" ]; then
|
|
771
798
|
HALFCYCLE_ENV_PROBLEM="no-home"
|
|
@@ -834,6 +861,264 @@ function mintOrReadIdentity(targetRepoRoot) {
|
|
|
834
861
|
return { identity, minted: true };
|
|
835
862
|
}
|
|
836
863
|
|
|
864
|
+
// dist/install-manifest.js
|
|
865
|
+
import { createHash } from "node:crypto";
|
|
866
|
+
var INSTALL_MANIFEST_REL = ".halfcycle/install-manifest.json";
|
|
867
|
+
var INSTALL_MANIFEST_FORMAT = "halfcycle-install-manifest/v1";
|
|
868
|
+
function sha256Hex(bytes) {
|
|
869
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
870
|
+
}
|
|
871
|
+
function canonicalJson(value) {
|
|
872
|
+
if (Array.isArray(value))
|
|
873
|
+
return value.map(canonicalJson);
|
|
874
|
+
if (value !== null && typeof value === "object") {
|
|
875
|
+
const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, canonicalJson(v)]);
|
|
876
|
+
return Object.fromEntries(entries);
|
|
877
|
+
}
|
|
878
|
+
return value;
|
|
879
|
+
}
|
|
880
|
+
function canonicalSha256(value) {
|
|
881
|
+
return sha256Hex(JSON.stringify(canonicalJson(value)));
|
|
882
|
+
}
|
|
883
|
+
function byString(a, b) {
|
|
884
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
885
|
+
}
|
|
886
|
+
function serializeManifest(manifest) {
|
|
887
|
+
const settings = {
|
|
888
|
+
...manifest.settings,
|
|
889
|
+
hookCommands: [...manifest.settings.hookCommands].sort(byString),
|
|
890
|
+
denyAddedSha256: [...manifest.settings.denyAddedSha256].sort(byString),
|
|
891
|
+
eventsCreated: [...manifest.settings.eventsCreated].sort(byString)
|
|
892
|
+
};
|
|
893
|
+
const ordered = {
|
|
894
|
+
...manifest,
|
|
895
|
+
files: [...manifest.files].sort((a, b) => byString(a.path, b.path)),
|
|
896
|
+
createdDirs: [...manifest.createdDirs].sort(byString),
|
|
897
|
+
leftAlone: [...manifest.leftAlone].sort(byString),
|
|
898
|
+
settings
|
|
899
|
+
};
|
|
900
|
+
return JSON.stringify(canonicalJson(ordered), null, 2) + "\n";
|
|
901
|
+
}
|
|
902
|
+
var UnreadableManifestError = class extends Error {
|
|
903
|
+
constructor(reason) {
|
|
904
|
+
super(reason);
|
|
905
|
+
this.name = "UnreadableManifestError";
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
var HEX64 = /^[0-9a-f]{64}$/;
|
|
909
|
+
function isRecord(value) {
|
|
910
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
911
|
+
}
|
|
912
|
+
function stringArray(value, field) {
|
|
913
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
914
|
+
throw new UnreadableManifestError(`"${field}" is not a list of strings`);
|
|
915
|
+
}
|
|
916
|
+
return value;
|
|
917
|
+
}
|
|
918
|
+
function hashArray(value, field) {
|
|
919
|
+
const list = stringArray(value, field);
|
|
920
|
+
if (!list.every((v) => HEX64.test(v)))
|
|
921
|
+
throw new UnreadableManifestError(`"${field}" holds a value that is not a hash`);
|
|
922
|
+
return list;
|
|
923
|
+
}
|
|
924
|
+
function bool(value, field) {
|
|
925
|
+
if (typeof value !== "boolean")
|
|
926
|
+
throw new UnreadableManifestError(`"${field}" is not true or false`);
|
|
927
|
+
return value;
|
|
928
|
+
}
|
|
929
|
+
function onlyKeys(value, allowed, where) {
|
|
930
|
+
const extra = Object.keys(value).filter((k) => !allowed.includes(k));
|
|
931
|
+
if (extra.length > 0)
|
|
932
|
+
throw new UnreadableManifestError(`${where} has a field this version does not know: ${extra.join(", ")}`);
|
|
933
|
+
}
|
|
934
|
+
function parseManifest(text) {
|
|
935
|
+
let raw;
|
|
936
|
+
try {
|
|
937
|
+
raw = JSON.parse(text);
|
|
938
|
+
} catch {
|
|
939
|
+
throw new UnreadableManifestError("it is not valid JSON");
|
|
940
|
+
}
|
|
941
|
+
if (!isRecord(raw))
|
|
942
|
+
throw new UnreadableManifestError("it is not a JSON object");
|
|
943
|
+
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
|
|
944
|
+
if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
|
|
945
|
+
throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
|
|
946
|
+
}
|
|
947
|
+
if (!Array.isArray(raw["files"]))
|
|
948
|
+
throw new UnreadableManifestError('"files" is not a list');
|
|
949
|
+
const files = raw["files"].map((entry) => {
|
|
950
|
+
if (!isRecord(entry) || typeof entry["path"] !== "string") {
|
|
951
|
+
throw new UnreadableManifestError('a "files" entry has no path');
|
|
952
|
+
}
|
|
953
|
+
if (entry["perMachine"] === true) {
|
|
954
|
+
onlyKeys(entry, ["path", "perMachine"], `the "files" entry for ${entry["path"]}`);
|
|
955
|
+
return { path: entry["path"], perMachine: true };
|
|
956
|
+
}
|
|
957
|
+
onlyKeys(entry, ["path", "sha256"], `the "files" entry for ${entry["path"]}`);
|
|
958
|
+
if (typeof entry["sha256"] !== "string" || !HEX64.test(entry["sha256"])) {
|
|
959
|
+
throw new UnreadableManifestError(`the "files" entry for ${entry["path"]} has no valid hash`);
|
|
960
|
+
}
|
|
961
|
+
return { path: entry["path"], sha256: entry["sha256"] };
|
|
962
|
+
});
|
|
963
|
+
const s = raw["settings"];
|
|
964
|
+
if (!isRecord(s))
|
|
965
|
+
throw new UnreadableManifestError('"settings" is missing');
|
|
966
|
+
onlyKeys(s, [
|
|
967
|
+
"created",
|
|
968
|
+
"adopted",
|
|
969
|
+
"hookCommands",
|
|
970
|
+
"denyAddedSha256",
|
|
971
|
+
"schemaBefore",
|
|
972
|
+
"eventsCreated",
|
|
973
|
+
"hooksCreated",
|
|
974
|
+
"permissionsCreated",
|
|
975
|
+
"denyCreated"
|
|
976
|
+
], '"settings"');
|
|
977
|
+
const settings = {
|
|
978
|
+
created: bool(s["created"], "settings.created"),
|
|
979
|
+
adopted: bool(s["adopted"], "settings.adopted"),
|
|
980
|
+
hookCommands: stringArray(s["hookCommands"], "settings.hookCommands"),
|
|
981
|
+
denyAddedSha256: hashArray(s["denyAddedSha256"], "settings.denyAddedSha256"),
|
|
982
|
+
..."schemaBefore" in s ? { schemaBefore: s["schemaBefore"] } : {},
|
|
983
|
+
eventsCreated: stringArray(s["eventsCreated"], "settings.eventsCreated"),
|
|
984
|
+
hooksCreated: bool(s["hooksCreated"], "settings.hooksCreated"),
|
|
985
|
+
permissionsCreated: bool(s["permissionsCreated"], "settings.permissionsCreated"),
|
|
986
|
+
denyCreated: bool(s["denyCreated"], "settings.denyCreated")
|
|
987
|
+
};
|
|
988
|
+
let mcp;
|
|
989
|
+
if (raw["mcp"] !== void 0) {
|
|
990
|
+
const m = raw["mcp"];
|
|
991
|
+
if (!isRecord(m))
|
|
992
|
+
throw new UnreadableManifestError('"mcp" is not an object');
|
|
993
|
+
onlyKeys(m, ["created", "adopted", "mcpServersCreated", "entrySha256"], '"mcp"');
|
|
994
|
+
if (m["entrySha256"] !== void 0 && (typeof m["entrySha256"] !== "string" || !HEX64.test(m["entrySha256"]))) {
|
|
995
|
+
throw new UnreadableManifestError('"mcp.entrySha256" is not a hash');
|
|
996
|
+
}
|
|
997
|
+
mcp = {
|
|
998
|
+
created: bool(m["created"], "mcp.created"),
|
|
999
|
+
adopted: bool(m["adopted"], "mcp.adopted"),
|
|
1000
|
+
mcpServersCreated: bool(m["mcpServersCreated"], "mcp.mcpServersCreated"),
|
|
1001
|
+
...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const g = raw["gitignore"];
|
|
1005
|
+
if (!isRecord(g))
|
|
1006
|
+
throw new UnreadableManifestError('"gitignore" is missing');
|
|
1007
|
+
onlyKeys(g, ["created", "appended"], '"gitignore"');
|
|
1008
|
+
const gitignore = {
|
|
1009
|
+
created: bool(g["created"], "gitignore.created"),
|
|
1010
|
+
appended: stringArray(g["appended"], "gitignore.appended")
|
|
1011
|
+
};
|
|
1012
|
+
const paths = files.map((f) => f.path);
|
|
1013
|
+
if (new Set(paths).size !== paths.length)
|
|
1014
|
+
throw new UnreadableManifestError('"files" names a path twice');
|
|
1015
|
+
const leftAlone = stringArray(raw["leftAlone"], "leftAlone");
|
|
1016
|
+
if (leftAlone.some((p) => paths.includes(p))) {
|
|
1017
|
+
throw new UnreadableManifestError('a path is in both "files" and "leftAlone"');
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
format: INSTALL_MANIFEST_FORMAT,
|
|
1021
|
+
files,
|
|
1022
|
+
createdDirs: stringArray(raw["createdDirs"], "createdDirs"),
|
|
1023
|
+
leftAlone,
|
|
1024
|
+
settings,
|
|
1025
|
+
...mcp !== void 0 ? { mcp } : {},
|
|
1026
|
+
gitignore
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
function startManifest(previous, isMember, isMemberDir) {
|
|
1030
|
+
const files = /* @__PURE__ */ new Map();
|
|
1031
|
+
for (const entry of previous?.files ?? []) {
|
|
1032
|
+
if (isMember(entry.path))
|
|
1033
|
+
files.set(entry.path, entry);
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
previous,
|
|
1037
|
+
files,
|
|
1038
|
+
collided: /* @__PURE__ */ new Set(),
|
|
1039
|
+
createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
|
|
1040
|
+
settings: previous?.settings,
|
|
1041
|
+
mcp: previous?.mcp,
|
|
1042
|
+
gitignore: previous?.gitignore
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
function recordInstalledFile(draft, path, bytes) {
|
|
1046
|
+
draft.files.set(path, { path, sha256: sha256Hex(bytes) });
|
|
1047
|
+
}
|
|
1048
|
+
function recordPerMachineFile(draft, path) {
|
|
1049
|
+
draft.files.set(path, { path, perMachine: true });
|
|
1050
|
+
}
|
|
1051
|
+
function recordCollision(draft, path) {
|
|
1052
|
+
draft.collided.add(path);
|
|
1053
|
+
}
|
|
1054
|
+
function recordCreatedDir(draft, path) {
|
|
1055
|
+
draft.createdDirs.add(path);
|
|
1056
|
+
}
|
|
1057
|
+
function recordSettings(draft, observed) {
|
|
1058
|
+
const prev = draft.settings;
|
|
1059
|
+
if (prev === void 0) {
|
|
1060
|
+
draft.settings = observed;
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
draft.settings = {
|
|
1064
|
+
created: prev.created,
|
|
1065
|
+
adopted: prev.adopted,
|
|
1066
|
+
hookCommands: union(prev.hookCommands, observed.hookCommands),
|
|
1067
|
+
denyAddedSha256: union(prev.denyAddedSha256, observed.denyAddedSha256),
|
|
1068
|
+
..."schemaBefore" in prev ? { schemaBefore: prev.schemaBefore } : "schemaBefore" in observed ? { schemaBefore: observed.schemaBefore } : {},
|
|
1069
|
+
eventsCreated: union(prev.eventsCreated, observed.eventsCreated),
|
|
1070
|
+
hooksCreated: prev.hooksCreated,
|
|
1071
|
+
permissionsCreated: prev.permissionsCreated,
|
|
1072
|
+
denyCreated: prev.denyCreated
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
function recordMcp(draft, observed) {
|
|
1076
|
+
const prev = draft.mcp;
|
|
1077
|
+
if (prev === void 0) {
|
|
1078
|
+
draft.mcp = observed;
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const entrySha256 = observed.entrySha256 ?? prev.entrySha256;
|
|
1082
|
+
draft.mcp = {
|
|
1083
|
+
created: prev.created,
|
|
1084
|
+
adopted: prev.adopted,
|
|
1085
|
+
mcpServersCreated: prev.mcpServersCreated,
|
|
1086
|
+
...entrySha256 !== void 0 ? { entrySha256 } : {}
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
function recordGitignore(draft, created, appended) {
|
|
1090
|
+
const prev = draft.gitignore;
|
|
1091
|
+
draft.gitignore = {
|
|
1092
|
+
created: prev?.created ?? created,
|
|
1093
|
+
appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
function finishManifest(draft) {
|
|
1097
|
+
const files = [...draft.files.values()];
|
|
1098
|
+
const leftAlone = [...draft.collided].filter((p) => !draft.files.has(p));
|
|
1099
|
+
return {
|
|
1100
|
+
format: INSTALL_MANIFEST_FORMAT,
|
|
1101
|
+
files,
|
|
1102
|
+
createdDirs: [...draft.createdDirs],
|
|
1103
|
+
leftAlone,
|
|
1104
|
+
settings: draft.settings ?? {
|
|
1105
|
+
created: false,
|
|
1106
|
+
adopted: false,
|
|
1107
|
+
hookCommands: [],
|
|
1108
|
+
denyAddedSha256: [],
|
|
1109
|
+
eventsCreated: [],
|
|
1110
|
+
hooksCreated: false,
|
|
1111
|
+
permissionsCreated: false,
|
|
1112
|
+
denyCreated: false
|
|
1113
|
+
},
|
|
1114
|
+
...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
|
|
1115
|
+
gitignore: draft.gitignore ?? { created: false, appended: [] }
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
function union(a, b) {
|
|
1119
|
+
return [.../* @__PURE__ */ new Set([...a, ...b])];
|
|
1120
|
+
}
|
|
1121
|
+
|
|
837
1122
|
// dist/mcp-endpoint.js
|
|
838
1123
|
var MCP_ENDPOINT_PATH = "/mcp";
|
|
839
1124
|
function mcpEndpointUrl(origin) {
|
|
@@ -919,8 +1204,9 @@ function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOSt
|
|
|
919
1204
|
}
|
|
920
1205
|
return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
|
|
921
1206
|
}
|
|
1207
|
+
var BOOTSTRAP_STATE_REL = ".halfcycle/state.json";
|
|
922
1208
|
function runBootstrapScan(targetRepo) {
|
|
923
|
-
const statePath = join4(targetRepo,
|
|
1209
|
+
const statePath = join4(targetRepo, BOOTSTRAP_STATE_REL);
|
|
924
1210
|
if (existsSync2(statePath)) {
|
|
925
1211
|
const existing = JSON.parse(readFileSync3(statePath, "utf-8"));
|
|
926
1212
|
return { state: existing, ran: false };
|
|
@@ -994,22 +1280,152 @@ function isAllowlisted(targetRelPath) {
|
|
|
994
1280
|
return normalised === p || normalised.startsWith(p + "/");
|
|
995
1281
|
});
|
|
996
1282
|
}
|
|
1283
|
+
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
1284
|
+
var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
|
|
1285
|
+
var CREW_ROSTER_REL = ".halfcycle/crew.json";
|
|
1286
|
+
var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
|
|
1287
|
+
var SETTINGS_REL = ".claude/settings.json";
|
|
1288
|
+
var GITIGNORE_REL = ".gitignore";
|
|
1289
|
+
function commandStubRel(name) {
|
|
1290
|
+
return `.claude/commands/${name}.md`;
|
|
1291
|
+
}
|
|
1292
|
+
var RETIRED_WRITE_SET_FILES = [];
|
|
1293
|
+
var RETIRED_HOOK_COMMANDS = [];
|
|
1294
|
+
var RETIRED_DENY_PATTERNS = [];
|
|
1295
|
+
function installerHookCommands() {
|
|
1296
|
+
const settings = JSON.parse(generateSettingsJson());
|
|
1297
|
+
return Object.values(settings.hooks ?? {}).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
|
|
1298
|
+
}
|
|
1299
|
+
function closedWriteSet() {
|
|
1300
|
+
const files = /* @__PURE__ */ new Set([
|
|
1301
|
+
...(readPluginManifest().commands ?? []).map((cmd) => commandStubRel(cmd.name)),
|
|
1302
|
+
...Object.keys(OWNED_GENERATED_HEADERS),
|
|
1303
|
+
VENDORED_BIN_REL,
|
|
1304
|
+
CREW_ROSTER_REL,
|
|
1305
|
+
BUNDLE_PIN_REL,
|
|
1306
|
+
PROJECT_IDENTITY_REL,
|
|
1307
|
+
BOOTSTRAP_STATE_REL,
|
|
1308
|
+
CAPTURED_INDEX_REL,
|
|
1309
|
+
INSTALL_MANIFEST_REL,
|
|
1310
|
+
...RETIRED_WRITE_SET_FILES
|
|
1311
|
+
]);
|
|
1312
|
+
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
|
|
1313
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
1314
|
+
for (const path of [...files, ...merged]) {
|
|
1315
|
+
for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
|
|
1316
|
+
dirs.add(dir);
|
|
1317
|
+
}
|
|
1318
|
+
const ownedDirs = new Set([...dirs].filter((dir) => `${dir}/`.startsWith(INSTALLER_OWNED_DIR)));
|
|
1319
|
+
return {
|
|
1320
|
+
files,
|
|
1321
|
+
merged,
|
|
1322
|
+
dirs,
|
|
1323
|
+
ownedDirs,
|
|
1324
|
+
hookCommands: /* @__PURE__ */ new Set([...installerHookCommands(), ...RETIRED_HOOK_COMMANDS]),
|
|
1325
|
+
denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
|
|
1326
|
+
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
function readPreviousManifest(targetRepo) {
|
|
1330
|
+
try {
|
|
1331
|
+
return parseManifest(readFileSync4(join5(targetRepo, INSTALL_MANIFEST_REL), "utf-8"));
|
|
1332
|
+
} catch {
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
function noteFile(draft, targetRepo, rel, outcome) {
|
|
1337
|
+
if (outcome === "collided") {
|
|
1338
|
+
recordCollision(draft, rel);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
recordInstalledFile(draft, rel, readFileSync4(join5(targetRepo, rel)));
|
|
1342
|
+
}
|
|
1343
|
+
function recordSettingsMerge(draft, existing, generated, preexisted) {
|
|
1344
|
+
const generatedHooks = generated.hooks ?? {};
|
|
1345
|
+
const hookCommands = Object.values(generatedHooks).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
|
|
1346
|
+
const ours = new Set(hookCommands);
|
|
1347
|
+
const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : void 0;
|
|
1348
|
+
const heldOurs = Object.values(existingHooks ?? {}).some((entries) => Array.isArray(entries) && entries.some((entry) => isHalfcycleEntry(entry, ours)));
|
|
1349
|
+
const existingDeny = Array.isArray(existing.permissions?.deny) ? existing.permissions.deny : [];
|
|
1350
|
+
const schemaChanged = generated.$schema !== void 0 && existing.$schema !== generated.$schema;
|
|
1351
|
+
recordSettings(draft, {
|
|
1352
|
+
created: !preexisted,
|
|
1353
|
+
adopted: draft.previous === null && heldOurs,
|
|
1354
|
+
hookCommands,
|
|
1355
|
+
denyAddedSha256: (generated.permissions?.deny ?? []).filter((rule) => !existingDeny.includes(rule)).map((rule) => sha256Hex(rule)),
|
|
1356
|
+
...schemaChanged ? { schemaBefore: existing.$schema ?? null } : {},
|
|
1357
|
+
eventsCreated: Object.keys(generatedHooks).filter((event) => !(existingHooks && event in existingHooks)),
|
|
1358
|
+
hooksCreated: existing.hooks === void 0,
|
|
1359
|
+
permissionsCreated: existing.permissions === void 0,
|
|
1360
|
+
denyCreated: existing.permissions?.deny === void 0
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
function recordMcpMerge(draft, existingText, writtenText) {
|
|
1364
|
+
const before = existingText === null ? void 0 : JSON.parse(existingText);
|
|
1365
|
+
const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
|
|
1366
|
+
const written = JSON.parse(writtenText);
|
|
1367
|
+
recordMcp(draft, {
|
|
1368
|
+
created: existingText === null,
|
|
1369
|
+
adopted: draft.previous === null && isPlainObject(servers) && MCP_SERVER_KEY in servers,
|
|
1370
|
+
mcpServersCreated: !(isPlainObject(before) && "mcpServers" in before),
|
|
1371
|
+
entrySha256: canonicalSha256(written.mcpServers[MCP_SERVER_KEY])
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
function recordMcpAdoptionOnly(draft, existingText) {
|
|
1375
|
+
if (draft.previous !== null || draft.mcp !== void 0)
|
|
1376
|
+
return;
|
|
1377
|
+
let before;
|
|
1378
|
+
try {
|
|
1379
|
+
before = JSON.parse(existingText);
|
|
1380
|
+
} catch {
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
|
|
1384
|
+
if (!isPlainObject(servers) || !(MCP_SERVER_KEY in servers))
|
|
1385
|
+
return;
|
|
1386
|
+
recordMcp(draft, { created: false, adopted: true, mcpServersCreated: false });
|
|
1387
|
+
}
|
|
1388
|
+
function recordGitignoreReconcile(draft, before, after) {
|
|
1389
|
+
const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
|
|
1390
|
+
let appended = "";
|
|
1391
|
+
if (after !== null && after !== before) {
|
|
1392
|
+
if (before === null)
|
|
1393
|
+
appended = after;
|
|
1394
|
+
else if (after.startsWith(before))
|
|
1395
|
+
appended = after.slice(before.length);
|
|
1396
|
+
}
|
|
1397
|
+
recordGitignore(draft, before === null && after !== null, [adopted, appended]);
|
|
1398
|
+
}
|
|
1399
|
+
function assertManifestInWriteSet(manifest, writeSet) {
|
|
1400
|
+
const strays = [
|
|
1401
|
+
...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
|
|
1402
|
+
...manifest.leftAlone.filter((p) => !writeSet.files.has(p)),
|
|
1403
|
+
...manifest.createdDirs.filter((p) => !writeSet.dirs.has(p))
|
|
1404
|
+
];
|
|
1405
|
+
if (strays.length > 0) {
|
|
1406
|
+
throw new Error(`[bundle install] the install record names paths outside the write set: ${strays.join(", ")}`);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
function isPlainObject(value) {
|
|
1410
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1411
|
+
}
|
|
997
1412
|
function readPluginManifest() {
|
|
998
1413
|
const manifestPath = join5(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
|
|
999
1414
|
const raw = readFileSync4(manifestPath, "utf-8");
|
|
1000
1415
|
return JSON.parse(raw);
|
|
1001
1416
|
}
|
|
1002
|
-
function copyManifestCommands(manifest, targetRepo, report) {
|
|
1003
|
-
const commandsTargetDir = join5(targetRepo, ".claude", "commands");
|
|
1417
|
+
function copyManifestCommands(manifest, targetRepo, report, draft) {
|
|
1004
1418
|
for (const cmd of manifest.commands ?? []) {
|
|
1005
1419
|
const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
|
|
1006
1420
|
if (!existsSync3(srcFile)) {
|
|
1007
1421
|
throw new Error(`[bundle install] plugin.json declares command "${cmd.name}" at "${cmd.path}", but no file exists there (${srcFile}). The manifest and commands/ must agree.`);
|
|
1008
1422
|
}
|
|
1009
|
-
const
|
|
1423
|
+
const rel = commandStubRel(cmd.name);
|
|
1424
|
+
const destFile = join5(targetRepo, rel);
|
|
1010
1425
|
const content = readFileSync4(srcFile, "utf-8");
|
|
1011
|
-
const
|
|
1012
|
-
record(report,
|
|
1426
|
+
const outcome = writeCollisionSafe(destFile, targetRepo, content);
|
|
1427
|
+
record(report, outcome, rel);
|
|
1428
|
+
noteFile(draft, targetRepo, rel, outcome);
|
|
1013
1429
|
}
|
|
1014
1430
|
}
|
|
1015
1431
|
function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
|
|
@@ -1075,6 +1491,7 @@ function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, ge
|
|
|
1075
1491
|
record(report, outcome, rel);
|
|
1076
1492
|
if (replacedExisting)
|
|
1077
1493
|
report.replacedPaths.push(rel);
|
|
1494
|
+
return outcome;
|
|
1078
1495
|
}
|
|
1079
1496
|
function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
|
|
1080
1497
|
const existedBefore = existsSync3(targetAbsPath);
|
|
@@ -1082,6 +1499,7 @@ function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
|
|
|
1082
1499
|
record(report, outcome, rel);
|
|
1083
1500
|
if (outcome === "written" && existedBefore)
|
|
1084
1501
|
report.replacedPaths.push(rel);
|
|
1502
|
+
return outcome;
|
|
1085
1503
|
}
|
|
1086
1504
|
function record(report, outcome, rel) {
|
|
1087
1505
|
const bucket = {
|
|
@@ -1406,101 +1824,53 @@ printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
|
|
|
1406
1824
|
exit 0
|
|
1407
1825
|
`;
|
|
1408
1826
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
#
|
|
1457
|
-
# WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
|
|
1458
|
-
# commits pushed straight to the default branch, which is the shape most
|
|
1459
|
-
# Halfcycle engagements settle on. The \`on:\` block is what runs it in both
|
|
1460
|
-
# places: every push, and every pull request. Narrow it if you want (for example
|
|
1461
|
-
# \`branches: [main]\` under \`push:\`), but keep \`pull_request\`, or a change is
|
|
1462
|
-
# first checked after it has merged. \`fetch-depth\` is what makes the check work
|
|
1463
|
-
# there: it needs the commit BEFORE the one it is evaluating, and the default
|
|
1464
|
-
# shallow checkout does not have it. With \`fetch-depth: 0\` the check fails loudly
|
|
1465
|
-
# if it cannot work out what to evaluate \u2014 it will not pass quietly having
|
|
1466
|
-
# evaluated nothing.
|
|
1467
|
-
#
|
|
1468
|
-
# name: Halfcycle guard
|
|
1469
|
-
# on:
|
|
1470
|
-
# push:
|
|
1471
|
-
# pull_request:
|
|
1472
|
-
# jobs:
|
|
1473
|
-
# halfcycle-guard-ci:
|
|
1474
|
-
# runs-on: ubuntu-latest
|
|
1475
|
-
# permissions:
|
|
1476
|
-
# contents: read
|
|
1477
|
-
# id-token: write
|
|
1478
|
-
# steps:
|
|
1479
|
-
# - uses: actions/checkout@v4
|
|
1480
|
-
# with:
|
|
1481
|
-
# # REQUIRED. 0 = full history. The check diffs against the commit before
|
|
1482
|
-
# # HEAD (or the fork point on a branch); the default depth of 1 has
|
|
1483
|
-
# # neither. Do not lower this.
|
|
1484
|
-
# fetch-depth: 0
|
|
1485
|
-
# - uses: actions/setup-node@v4
|
|
1486
|
-
# with:
|
|
1487
|
-
# node-version: '20'
|
|
1488
|
-
# - name: Halfcycle guard CI check
|
|
1489
|
-
# # No env block, on purpose: this step holds no secret. The permissions
|
|
1490
|
-
# # above are what authenticate it.
|
|
1491
|
-
# run: node ./.halfcycle/bin/bin.bundle.mjs ci
|
|
1492
|
-
#
|
|
1493
|
-
# The job prints the diff base it used and how many files it evaluated, on every
|
|
1494
|
-
# run. If that line says 0 files on a commit that changed something, the base is
|
|
1495
|
-
# wrong \u2014 set HALFCYCLE_DIFF_BASE in an env block OF YOUR COPY of the check step,
|
|
1496
|
-
# to name it explicitly (on a GitHub push event, \${{ github.event.before }} is the
|
|
1497
|
-
# right value).
|
|
1498
|
-
#
|
|
1499
|
-
# EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
|
|
1500
|
-
# changes to it would be replaced the next time you run \`npx halfcycle\`. It is
|
|
1501
|
-
# also inert where it sits: no CI system reads this path. Copy the workflow above
|
|
1502
|
-
# \u2014 or just its job \u2014 into .github/workflows/ and change it there.
|
|
1503
|
-
`;
|
|
1827
|
+
var CI_STANZA_KNOWN_HEADER_HASHES = [
|
|
1828
|
+
"b225800b2d699811f32ac213238cbf81c35b5e6a74114bdf55e39cd9d7e2928f",
|
|
1829
|
+
"674181735460f051af7c157222cf851fc3000fb3b8af3a10ea0bde9fe5eaba1f",
|
|
1830
|
+
"d17bde608a6fec54a63cb934a28d88acc8babc205334b5ee84d4ed6ee219d340",
|
|
1831
|
+
"85e71d3f6551977b241a28468f121ee4d1c4edef40a2c4dc1aa6f2f737ebd037"
|
|
1832
|
+
];
|
|
1833
|
+
function isTrackedAndClean(targetRepo, relPath) {
|
|
1834
|
+
try {
|
|
1835
|
+
execFileSync2("git", ["-C", targetRepo, "ls-files", "--error-unmatch", "--", relPath], {
|
|
1836
|
+
stdio: "ignore"
|
|
1837
|
+
});
|
|
1838
|
+
} catch {
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
try {
|
|
1842
|
+
execFileSync2("git", ["-C", targetRepo, "diff", "--quiet", "HEAD", "--", relPath], {
|
|
1843
|
+
stdio: "ignore"
|
|
1844
|
+
});
|
|
1845
|
+
} catch {
|
|
1846
|
+
return false;
|
|
1847
|
+
}
|
|
1848
|
+
return true;
|
|
1849
|
+
}
|
|
1850
|
+
function removeLegacyCiStanza(targetRepo) {
|
|
1851
|
+
const rel = ".halfcycle/ci-stanza.yml";
|
|
1852
|
+
const path = join5(targetRepo, rel);
|
|
1853
|
+
if (!existsSync3(path))
|
|
1854
|
+
return "absent";
|
|
1855
|
+
let raw;
|
|
1856
|
+
try {
|
|
1857
|
+
raw = readFileSync4(path, "utf-8");
|
|
1858
|
+
} catch {
|
|
1859
|
+
return "failed";
|
|
1860
|
+
}
|
|
1861
|
+
const headerLine = raw.split("\n")[0] ?? "";
|
|
1862
|
+
const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
|
|
1863
|
+
const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
|
|
1864
|
+
if (!ours)
|
|
1865
|
+
return "kept-foreign";
|
|
1866
|
+
if (!isTrackedAndClean(targetRepo, rel))
|
|
1867
|
+
return "kept-uncommitted";
|
|
1868
|
+
try {
|
|
1869
|
+
rmSync(path);
|
|
1870
|
+
} catch {
|
|
1871
|
+
return "failed";
|
|
1872
|
+
}
|
|
1873
|
+
return "removed";
|
|
1504
1874
|
}
|
|
1505
1875
|
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
1506
1876
|
var MCP_SERVER_KEY = "halfcycle";
|
|
@@ -1542,13 +1912,81 @@ PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
|
|
1542
1912
|
|
|
1543
1913
|
${engagementResolutionShell()}
|
|
1544
1914
|
|
|
1915
|
+
# Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
|
|
1916
|
+
# shell (call it as a statement, never inside $(...)). Last assignment wins,
|
|
1917
|
+
# matching the shell's own \`.\` semantics. An optional leading \`export \` is
|
|
1918
|
+
# tolerated because a hand-edited store may use one.
|
|
1919
|
+
halfcycle_store_value() {
|
|
1920
|
+
HC_VALUE=""
|
|
1921
|
+
hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
|
|
1922
|
+
hc_v=\${hc_line#*"$2="}
|
|
1923
|
+
hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
|
|
1924
|
+
|
|
1925
|
+
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
1926
|
+
# writers use the same shq: this installer and Studio's provider), because
|
|
1927
|
+
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
1928
|
+
# would execute its own remainder. This reader greps rather than sources, so it
|
|
1929
|
+
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
1930
|
+
# sourcing consumer gets, or one file has two answers.
|
|
1931
|
+
case $hc_v in
|
|
1932
|
+
'"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
|
|
1933
|
+
"'"*"'")
|
|
1934
|
+
hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
|
|
1935
|
+
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
1936
|
+
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
1937
|
+
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
1938
|
+
# no shell quoting is in the way:
|
|
1939
|
+
#
|
|
1940
|
+
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
1941
|
+
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
1942
|
+
#
|
|
1943
|
+
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
1944
|
+
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
1945
|
+
# and the token then travels with four stray characters in it. The bracket
|
|
1946
|
+
# expression makes the backslash literal by a construction POSIX does define.
|
|
1947
|
+
hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
|
|
1948
|
+
esac
|
|
1949
|
+
HC_VALUE=$hc_v
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
# The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
|
|
1953
|
+
# a space or a control character anywhere. That refusal is the load-bearing part:
|
|
1954
|
+
# a URL parser drops tabs and newlines before it reads the host, so
|
|
1955
|
+
# "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
|
|
1956
|
+
# line-by-line reading here would see only "https://ours". The host ends at the
|
|
1957
|
+
# first / ? # or backslash, which is where a URL parser ends it for http(s).
|
|
1958
|
+
halfcycle_origin() {
|
|
1959
|
+
hc_u=$1
|
|
1960
|
+
if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
|
|
1961
|
+
return 0
|
|
1962
|
+
fi
|
|
1963
|
+
printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1545
1966
|
# HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
|
|
1546
1967
|
# browser and no per-user store exports the credential, and a stale store on a
|
|
1547
1968
|
# long-lived runner must not silently win over it. Same order, and the same reason,
|
|
1548
|
-
# as the CLI's own resolver (\`resolve-credential.ts\`).
|
|
1969
|
+
# as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
|
|
1970
|
+
# to is NOT taken from the environment: it is read from this machine's store in
|
|
1971
|
+
# both arms, so a token supplied this way still goes only where this machine was
|
|
1972
|
+
# set up to send it.
|
|
1973
|
+
# The pin names an id that is not a project id. Re-running the installer against
|
|
1974
|
+
# that pin refuses it too, so the one remedy that works is a fresh pin.
|
|
1975
|
+
halfcycle_bad_id_message() {
|
|
1976
|
+
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
|
|
1977
|
+
echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
|
|
1978
|
+
echo "halfcycle: is then set up as a new Halfcycle project." >&2
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1549
1981
|
TOKEN=\${HALFCYCLE_TOKEN:-}
|
|
1982
|
+
MCP_URL=""
|
|
1550
1983
|
|
|
1551
|
-
if [ -
|
|
1984
|
+
if [ -n "$TOKEN" ]; then
|
|
1985
|
+
if halfcycle_env_file "$PROJECT_ROOT"; then
|
|
1986
|
+
halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
|
|
1987
|
+
MCP_URL=$HC_VALUE
|
|
1988
|
+
fi
|
|
1989
|
+
else
|
|
1552
1990
|
if ! halfcycle_env_file "$PROJECT_ROOT"; then
|
|
1553
1991
|
case $HALFCYCLE_ENV_PROBLEM in
|
|
1554
1992
|
no-pin)
|
|
@@ -1557,6 +1995,8 @@ if [ -z "$TOKEN" ]; then
|
|
|
1557
1995
|
no-id)
|
|
1558
1996
|
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
|
|
1559
1997
|
echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
|
|
1998
|
+
bad-id)
|
|
1999
|
+
halfcycle_bad_id_message ;;
|
|
1560
2000
|
no-home)
|
|
1561
2001
|
echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
|
|
1562
2002
|
*)
|
|
@@ -1579,42 +2019,48 @@ if [ -z "$TOKEN" ]; then
|
|
|
1579
2019
|
fi
|
|
1580
2020
|
ENV_FILE="$HALFCYCLE_ENV_FILE"
|
|
1581
2021
|
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
LINE=$(grep '^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}HALFCYCLE_TOKEN=' "$ENV_FILE" | tail -n 1)
|
|
1585
|
-
TOKEN=\${LINE#*HALFCYCLE_TOKEN=}
|
|
1586
|
-
TOKEN=$(printf '%s' "$TOKEN" | tr -d '\\r')
|
|
1587
|
-
|
|
1588
|
-
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
1589
|
-
# writers use the same shq: this installer and Studio's provider), because
|
|
1590
|
-
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
1591
|
-
# would execute its own remainder. This reader greps rather than sources, so it
|
|
1592
|
-
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
1593
|
-
# sourcing consumer gets, or one file has two answers.
|
|
1594
|
-
case $TOKEN in
|
|
1595
|
-
'"'*'"') TOKEN=\${TOKEN#'"'}; TOKEN=\${TOKEN%'"'} ;;
|
|
1596
|
-
"'"*"'")
|
|
1597
|
-
TOKEN=\${TOKEN#"'"}; TOKEN=\${TOKEN%"'"}
|
|
1598
|
-
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
1599
|
-
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
1600
|
-
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
1601
|
-
# no shell quoting is in the way:
|
|
1602
|
-
#
|
|
1603
|
-
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
1604
|
-
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
1605
|
-
#
|
|
1606
|
-
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
1607
|
-
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
1608
|
-
# and the token then travels with four stray characters in it. The bracket
|
|
1609
|
-
# expression makes the backslash literal by a construction POSIX does define.
|
|
1610
|
-
TOKEN=$(printf '%s' "$TOKEN" | sed "s/'[\\]''/'/g") ;;
|
|
1611
|
-
esac
|
|
1612
|
-
|
|
2022
|
+
halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
|
|
2023
|
+
TOKEN=$HC_VALUE
|
|
1613
2024
|
if [ -z "$TOKEN" ]; then
|
|
1614
2025
|
echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
|
|
1615
2026
|
echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
|
|
1616
2027
|
exit 1
|
|
1617
2028
|
fi
|
|
2029
|
+
halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
|
|
2030
|
+
MCP_URL=$HC_VALUE
|
|
2031
|
+
fi
|
|
2032
|
+
|
|
2033
|
+
# THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
|
|
2034
|
+
# file: anyone who can land a commit can change the server's url, and this script
|
|
2035
|
+
# would then hand the credential to whatever it names. Claude Code tells the helper
|
|
2036
|
+
# which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
|
|
2037
|
+
# machine was set up against is recorded OUTSIDE the repository, beside the
|
|
2038
|
+
# credential. The two must share scheme, host and port, or nothing is printed.
|
|
2039
|
+
# No url at all is a refusal too: sending the credential without knowing where it
|
|
2040
|
+
# is going is the thing this check exists to stop.
|
|
2041
|
+
REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
|
|
2042
|
+
if [ -z "$REQUESTED" ]; then
|
|
2043
|
+
echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
|
|
2044
|
+
echo "halfcycle: update Claude Code, then reconnect." >&2
|
|
2045
|
+
exit 1
|
|
2046
|
+
fi
|
|
2047
|
+
if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
|
|
2048
|
+
halfcycle_bad_id_message
|
|
2049
|
+
exit 1
|
|
2050
|
+
fi
|
|
2051
|
+
if [ -z "$MCP_URL" ]; then
|
|
2052
|
+
echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
|
|
2053
|
+
echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
|
|
2054
|
+
exit 1
|
|
2055
|
+
fi
|
|
2056
|
+
WANT=$(halfcycle_origin "$MCP_URL")
|
|
2057
|
+
GOT=$(halfcycle_origin "$REQUESTED")
|
|
2058
|
+
if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
|
|
2059
|
+
SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
|
|
2060
|
+
echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
|
|
2061
|
+
echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
|
|
2062
|
+
echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
|
|
2063
|
+
exit 1
|
|
1618
2064
|
fi
|
|
1619
2065
|
|
|
1620
2066
|
# JSON-escape: backslash first, then double quote. A token carrying either would
|
|
@@ -1630,7 +2076,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
|
|
|
1630
2076
|
if (existing !== null) {
|
|
1631
2077
|
const parsed = JSON.parse(existing);
|
|
1632
2078
|
if (parsed !== null && typeof parsed === "object") {
|
|
1633
|
-
base = {
|
|
2079
|
+
base = { ...parsed };
|
|
1634
2080
|
}
|
|
1635
2081
|
}
|
|
1636
2082
|
if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
|
|
@@ -1644,13 +2090,33 @@ function generateMcpRegistration(existing, mcpOrigin) {
|
|
|
1644
2090
|
return JSON.stringify(base, null, 2) + "\n";
|
|
1645
2091
|
}
|
|
1646
2092
|
var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
|
|
2093
|
+
var GITIGNORE_HEADER = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
|
|
2094
|
+
function adoptOlderGitignoreBlock(before) {
|
|
2095
|
+
const claimable = /* @__PURE__ */ new Set([...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]);
|
|
2096
|
+
const claimed = [];
|
|
2097
|
+
let underHeader = false;
|
|
2098
|
+
const lines = before.split("\n");
|
|
2099
|
+
for (const [i, line] of lines.entries()) {
|
|
2100
|
+
if (line === GITIGNORE_HEADER) {
|
|
2101
|
+
if (i > 0 && lines[i - 1] === "")
|
|
2102
|
+
claimed.push("");
|
|
2103
|
+
claimed.push(line);
|
|
2104
|
+
underHeader = true;
|
|
2105
|
+
} else if (line.trim() === "") {
|
|
2106
|
+
underHeader = false;
|
|
2107
|
+
} else if (underHeader && claimable.has(line)) {
|
|
2108
|
+
claimed.push(line);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
return claimed.length > 0 ? claimed.join("\n") + "\n" : "";
|
|
2112
|
+
}
|
|
1647
2113
|
function gitignoreCovers(content) {
|
|
1648
2114
|
const lines = new Set(content.split("\n").map((l) => l.trim()));
|
|
1649
2115
|
return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
|
|
1650
2116
|
}
|
|
1651
2117
|
function reconcileGitignore(targetRepo) {
|
|
1652
|
-
const gitignorePath = join5(targetRepo,
|
|
1653
|
-
const header =
|
|
2118
|
+
const gitignorePath = join5(targetRepo, GITIGNORE_REL);
|
|
2119
|
+
const header = GITIGNORE_HEADER;
|
|
1654
2120
|
try {
|
|
1655
2121
|
if (!existsSync3(gitignorePath)) {
|
|
1656
2122
|
const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
|
|
@@ -1695,7 +2161,7 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, a
|
|
|
1695
2161
|
installedAt,
|
|
1696
2162
|
...carried !== void 0 ? { accountId: carried } : {}
|
|
1697
2163
|
};
|
|
1698
|
-
const pinPath = join5(targetRepoRoot,
|
|
2164
|
+
const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
|
|
1699
2165
|
writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
|
|
1700
2166
|
}
|
|
1701
2167
|
function writeCrewRoster(targetRepoRoot, report) {
|
|
@@ -1705,11 +2171,11 @@ function writeCrewRoster(targetRepoRoot, report) {
|
|
|
1705
2171
|
};
|
|
1706
2172
|
const rendered = `${JSON.stringify(doc, null, 2)}
|
|
1707
2173
|
`;
|
|
1708
|
-
const crewPath = join5(targetRepoRoot,
|
|
1709
|
-
recordOwned(report, crewPath, targetRepoRoot, rendered,
|
|
2174
|
+
const crewPath = join5(targetRepoRoot, CREW_ROSTER_REL);
|
|
2175
|
+
return recordOwned(report, crewPath, targetRepoRoot, rendered, CREW_ROSTER_REL);
|
|
1710
2176
|
}
|
|
1711
2177
|
function readBundlePin(targetRepoRoot) {
|
|
1712
|
-
const pinPath = join5(targetRepoRoot,
|
|
2178
|
+
const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
|
|
1713
2179
|
if (!existsSync3(pinPath))
|
|
1714
2180
|
return null;
|
|
1715
2181
|
return JSON.parse(readFileSync4(pinPath, "utf-8"));
|
|
@@ -1733,7 +2199,7 @@ function checkDrift(targetRepoRoot) {
|
|
|
1733
2199
|
return { drifted: installed !== current, installed, current };
|
|
1734
2200
|
}
|
|
1735
2201
|
function writeProjectIdentity(targetRepo) {
|
|
1736
|
-
const path = join5(targetRepo,
|
|
2202
|
+
const path = join5(targetRepo, PROJECT_IDENTITY_REL);
|
|
1737
2203
|
const { identity } = mintOrReadIdentity(targetRepo);
|
|
1738
2204
|
const serialized = JSON.stringify(identity, null, 2) + "\n";
|
|
1739
2205
|
if (existsSync3(path) && readFileSync4(path, "utf-8") === serialized)
|
|
@@ -1812,6 +2278,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
|
|
|
1812
2278
|
}
|
|
1813
2279
|
async function install(options) {
|
|
1814
2280
|
const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
|
|
2281
|
+
assertEngagementId(engagementId);
|
|
1815
2282
|
if (!existsSync3(targetRepo)) {
|
|
1816
2283
|
throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
|
|
1817
2284
|
}
|
|
@@ -1823,22 +2290,30 @@ async function install(options) {
|
|
|
1823
2290
|
collidedPaths: [],
|
|
1824
2291
|
replacedPaths: []
|
|
1825
2292
|
};
|
|
1826
|
-
|
|
1827
|
-
const
|
|
1828
|
-
const
|
|
2293
|
+
const writeSet = closedWriteSet();
|
|
2294
|
+
const draft = startManifest(readPreviousManifest(targetRepo), (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
|
|
2295
|
+
const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
|
|
2296
|
+
copyManifestCommands(manifest, targetRepo, report, draft);
|
|
2297
|
+
const capturedManifest = join5(targetRepo, CAPTURED_INDEX_REL);
|
|
2298
|
+
const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
|
|
1829
2299
|
if (!existsSync3(capturedManifest)) {
|
|
1830
|
-
const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
|
|
1831
2300
|
writeAllowlisted(capturedManifest, targetRepo, readFileSync4(srcManifest, "utf-8"), report.writtenPaths);
|
|
2301
|
+
noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "written");
|
|
1832
2302
|
} else {
|
|
1833
|
-
report.skippedPaths.push(
|
|
2303
|
+
report.skippedPaths.push(CAPTURED_INDEX_REL);
|
|
2304
|
+
if (readFileSync4(capturedManifest).equals(readFileSync4(srcManifest))) {
|
|
2305
|
+
noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "skipped");
|
|
2306
|
+
}
|
|
1834
2307
|
}
|
|
1835
2308
|
const vendoredBinSrc = resolveVendoredBinary();
|
|
1836
2309
|
const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
|
|
1837
|
-
recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"),
|
|
1838
|
-
|
|
2310
|
+
const vendoredOutcome = recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"), VENDORED_BIN_REL);
|
|
2311
|
+
noteFile(draft, targetRepo, VENDORED_BIN_REL, vendoredOutcome);
|
|
2312
|
+
const settingsPath = join5(targetRepo, SETTINGS_REL);
|
|
1839
2313
|
const settingsPreexisted = existsSync3(settingsPath);
|
|
1840
2314
|
const generatedSettings = JSON.parse(generateSettingsJson());
|
|
1841
2315
|
const existingSettings = settingsPreexisted ? JSON.parse(readFileSync4(settingsPath, "utf-8")) : {};
|
|
2316
|
+
recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted);
|
|
1842
2317
|
const mergedSettings = mergeSettings(existingSettings, generatedSettings);
|
|
1843
2318
|
const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
|
|
1844
2319
|
mkdirSync3(dirname2(settingsPath), { recursive: true });
|
|
@@ -1852,16 +2327,33 @@ async function install(options) {
|
|
|
1852
2327
|
]) {
|
|
1853
2328
|
const rel = `.claude/hooks/${name}`;
|
|
1854
2329
|
const hookPath = join5(targetRepo, ".claude", "hooks", name);
|
|
1855
|
-
recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
|
|
2330
|
+
const outcome = recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
|
|
2331
|
+
noteFile(draft, targetRepo, rel, outcome);
|
|
2332
|
+
}
|
|
2333
|
+
switch (removeLegacyCiStanza(targetRepo)) {
|
|
2334
|
+
case "removed":
|
|
2335
|
+
report.writtenPaths.push(".halfcycle/ci-stanza.yml (REMOVED \u2014 Halfcycle no longer generates this file)");
|
|
2336
|
+
break;
|
|
2337
|
+
case "kept-foreign":
|
|
2338
|
+
report.skippedPaths.push(".halfcycle/ci-stanza.yml (KEPT \u2014 this file was not generated by Halfcycle, so it was left alone)");
|
|
2339
|
+
break;
|
|
2340
|
+
case "kept-uncommitted":
|
|
2341
|
+
report.skippedPaths.push(".halfcycle/ci-stanza.yml (KEPT \u2014 this looks like a Halfcycle-generated file, but it is not a clean, committed copy in this repository, so it was left alone rather than risk losing an edit)");
|
|
2342
|
+
break;
|
|
2343
|
+
case "failed":
|
|
2344
|
+
report.skippedPaths.push(".halfcycle/ci-stanza.yml (could not be removed \u2014 DELETE IT BY HAND: Halfcycle no longer uses this file)");
|
|
2345
|
+
break;
|
|
2346
|
+
case "absent":
|
|
2347
|
+
break;
|
|
1856
2348
|
}
|
|
1857
|
-
const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
|
|
1858
|
-
recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
|
|
1859
2349
|
if (credential) {
|
|
1860
2350
|
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
1861
|
-
recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
|
|
2351
|
+
const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
|
|
2352
|
+
noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
|
|
1862
2353
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
1863
2354
|
const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
|
|
1864
2355
|
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
2356
|
+
recordMcpMerge(draft, existingMcp, mcpContent);
|
|
1865
2357
|
if (existingMcp === null) {
|
|
1866
2358
|
writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
|
|
1867
2359
|
} else if (existingMcp !== mcpContent) {
|
|
@@ -1871,14 +2363,22 @@ async function install(options) {
|
|
|
1871
2363
|
}
|
|
1872
2364
|
} else {
|
|
1873
2365
|
report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
|
|
2366
|
+
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
2367
|
+
if (existsSync3(mcpPath))
|
|
2368
|
+
recordMcpAdoptionOnly(draft, readFileSync4(mcpPath, "utf-8"));
|
|
1874
2369
|
}
|
|
2370
|
+
const gitignorePath = join5(targetRepo, GITIGNORE_REL);
|
|
2371
|
+
const gitignoreBefore = existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null;
|
|
1875
2372
|
const gitignoreOutcome = reconcileGitignore(targetRepo);
|
|
2373
|
+
recordGitignoreReconcile(draft, gitignoreBefore, existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null);
|
|
1876
2374
|
if (gitignoreOutcome === "failed") {
|
|
1877
2375
|
report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
|
|
1878
2376
|
} else {
|
|
1879
2377
|
record(report, gitignoreOutcome, ".gitignore");
|
|
1880
2378
|
}
|
|
1881
|
-
|
|
2379
|
+
const identityOutcome = writeProjectIdentity(targetRepo);
|
|
2380
|
+
record(report, identityOutcome, PROJECT_IDENTITY_REL);
|
|
2381
|
+
noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
|
|
1882
2382
|
const credentialPath = engagementEnvPath(engagementId, home);
|
|
1883
2383
|
if (credential) {
|
|
1884
2384
|
record(report, writeEngagementCredential(credential, engagementId, home), credentialPath);
|
|
@@ -1904,8 +2404,18 @@ async function install(options) {
|
|
|
1904
2404
|
break;
|
|
1905
2405
|
}
|
|
1906
2406
|
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
|
|
1907
|
-
|
|
2407
|
+
noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
|
|
2408
|
+
noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
|
|
1908
2409
|
const scanResult = runBootstrapScan(targetRepo);
|
|
2410
|
+
if (existsSync3(join5(targetRepo, BOOTSTRAP_STATE_REL)))
|
|
2411
|
+
recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
|
|
2412
|
+
for (const dir of writeSet.dirs) {
|
|
2413
|
+
if (!dirsBefore.has(dir) && existsSync3(join5(targetRepo, dir)))
|
|
2414
|
+
recordCreatedDir(draft, dir);
|
|
2415
|
+
}
|
|
2416
|
+
const installManifest = finishManifest(draft);
|
|
2417
|
+
assertManifestInWriteSet(installManifest, writeSet);
|
|
2418
|
+
recordOwned(report, join5(targetRepo, INSTALL_MANIFEST_REL), targetRepo, serializeManifest(installManifest), INSTALL_MANIFEST_REL);
|
|
1909
2419
|
return {
|
|
1910
2420
|
version: manifest.version,
|
|
1911
2421
|
writtenPaths: report.writtenPaths,
|
|
@@ -2096,7 +2606,7 @@ async function mintBoardEnterCode(baseUrl, sessionToken) {
|
|
|
2096
2606
|
|
|
2097
2607
|
// dist/setup/manifest.js
|
|
2098
2608
|
import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2099
|
-
import { dirname as dirname3, join as join6, posix } from "node:path";
|
|
2609
|
+
import { dirname as dirname3, join as join6, posix as posix2 } from "node:path";
|
|
2100
2610
|
var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
|
|
2101
2611
|
var DECLARED_DIR_PREFIX = "docs";
|
|
2102
2612
|
var RESERVED_METHOD_PATH = "docs/method";
|
|
@@ -2107,7 +2617,7 @@ function normalise(path) {
|
|
|
2107
2617
|
const slashed = path.replace(/\\/g, "/");
|
|
2108
2618
|
if (slashed.trim() === "")
|
|
2109
2619
|
return "";
|
|
2110
|
-
return
|
|
2620
|
+
return posix2.normalize(slashed).replace(/\/+$/, "");
|
|
2111
2621
|
}
|
|
2112
2622
|
function isUnder(candidate, prefix) {
|
|
2113
2623
|
return candidate === prefix || candidate.startsWith(prefix + "/");
|
|
@@ -2557,11 +3067,11 @@ function projectSeedGuard(fired, includeExplanation) {
|
|
|
2557
3067
|
|
|
2558
3068
|
// dist/build-record/sources.js
|
|
2559
3069
|
import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
|
|
2560
|
-
import { createHash } from "node:crypto";
|
|
3070
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
2561
3071
|
import { join as join10 } from "node:path";
|
|
2562
3072
|
|
|
2563
3073
|
// dist/build-record/close-record.js
|
|
2564
|
-
import { execFileSync as
|
|
3074
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2565
3075
|
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2566
3076
|
import { dirname as dirname4, join as join8 } from "node:path";
|
|
2567
3077
|
var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
|
|
@@ -2578,11 +3088,11 @@ function closeRecordPath(repoRoot, phase) {
|
|
|
2578
3088
|
}
|
|
2579
3089
|
function resolveCloseAtHead(repoRoot) {
|
|
2580
3090
|
try {
|
|
2581
|
-
const closeCommit =
|
|
3091
|
+
const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
|
|
2582
3092
|
encoding: "utf-8",
|
|
2583
3093
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2584
3094
|
}).trim();
|
|
2585
|
-
const closedDate =
|
|
3095
|
+
const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
|
|
2586
3096
|
encoding: "utf-8",
|
|
2587
3097
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2588
3098
|
}).trim();
|
|
@@ -2806,7 +3316,7 @@ function syntheticRunId(record2) {
|
|
|
2806
3316
|
record2["runType"],
|
|
2807
3317
|
record2["phase"]
|
|
2808
3318
|
].join("|");
|
|
2809
|
-
const hex =
|
|
3319
|
+
const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
|
|
2810
3320
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
2811
3321
|
}
|
|
2812
3322
|
function readGuardEvalLog(logDir, phaseId) {
|