halfcycle 0.3.25 → 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 +167 -66
- package/dist/bin.d.ts +11 -5
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +1359 -333
- 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 +683 -149
- 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 +152 -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 +4 -3
- package/dist/ci-bind.d.ts +0 -116
- package/dist/ci-bind.d.ts.map +0 -1
package/dist/bin.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// dist/bin.js
|
|
4
|
-
import { execFileSync as
|
|
5
|
-
import { readFileSync as
|
|
6
|
-
import { join as
|
|
4
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
5
|
+
import { existsSync as existsSync5, readFileSync as readFileSync10, statSync } from "node:fs";
|
|
6
|
+
import { join as join12 } from "node:path";
|
|
7
7
|
|
|
8
8
|
// dist/install.js
|
|
9
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
10
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
9
11
|
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
10
|
-
import { dirname as dirname2, join as join5, relative } from "node:path";
|
|
12
|
+
import { dirname as dirname2, join as join5, posix, relative } from "node:path";
|
|
11
13
|
import { fileURLToPath } from "node:url";
|
|
12
14
|
|
|
13
15
|
// ../events/dist/result.js
|
|
@@ -36,6 +38,11 @@ var ENGAGEMENTS_DIR_NAME = "engagements";
|
|
|
36
38
|
var ENGAGEMENT_ENV_FILENAME = "env";
|
|
37
39
|
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
38
40
|
var ACCOUNT_STORE_FILENAME = "account.json";
|
|
41
|
+
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}$";
|
|
42
|
+
var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
|
|
43
|
+
function isEngagementId(value) {
|
|
44
|
+
return typeof value === "string" && ENGAGEMENT_ID_SHAPE.test(value);
|
|
45
|
+
}
|
|
39
46
|
var ENGAGEMENT_ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
|
|
40
47
|
function shq(value) {
|
|
41
48
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -1024,7 +1031,19 @@ var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
|
|
|
1024
1031
|
var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
|
|
1025
1032
|
var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
|
|
1026
1033
|
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
1034
|
+
var InvalidEngagementIdError = class extends Error {
|
|
1035
|
+
constructor(engagementId) {
|
|
1036
|
+
const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
|
|
1037
|
+
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.`);
|
|
1038
|
+
this.name = "InvalidEngagementIdError";
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
function assertEngagementId(engagementId) {
|
|
1042
|
+
if (!isEngagementId(engagementId))
|
|
1043
|
+
throw new InvalidEngagementIdError(engagementId);
|
|
1044
|
+
}
|
|
1027
1045
|
function engagementStateDir(engagementId, home) {
|
|
1046
|
+
assertEngagementId(engagementId);
|
|
1028
1047
|
return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
|
|
1029
1048
|
}
|
|
1030
1049
|
function engagementEnvPath(engagementId, home) {
|
|
@@ -1127,6 +1146,14 @@ halfcycle_env_file() {
|
|
|
1127
1146
|
HALFCYCLE_ENV_PROBLEM="no-id"
|
|
1128
1147
|
return 1
|
|
1129
1148
|
fi
|
|
1149
|
+
# The id is about to become a path under $HOME, and it came from a committed
|
|
1150
|
+
# file: anything that is not a project id (a "../", a "/") is refused here,
|
|
1151
|
+
# before the path exists. hc_id holds no newline (the pin was flattened above),
|
|
1152
|
+
# so grep sees exactly one line.
|
|
1153
|
+
if ! printf '%s\\n' "$hc_id" | grep -Eq '${ENGAGEMENT_ID_PATTERN}'; then
|
|
1154
|
+
HALFCYCLE_ENV_PROBLEM="bad-id"
|
|
1155
|
+
return 1
|
|
1156
|
+
fi
|
|
1130
1157
|
HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
|
|
1131
1158
|
if [ -z "\${HOME:-}" ]; then
|
|
1132
1159
|
HALFCYCLE_ENV_PROBLEM="no-home"
|
|
@@ -1202,6 +1229,264 @@ function mintOrReadIdentity(targetRepoRoot) {
|
|
|
1202
1229
|
return { identity, minted: true };
|
|
1203
1230
|
}
|
|
1204
1231
|
|
|
1232
|
+
// dist/install-manifest.js
|
|
1233
|
+
import { createHash } from "node:crypto";
|
|
1234
|
+
var INSTALL_MANIFEST_REL = ".halfcycle/install-manifest.json";
|
|
1235
|
+
var INSTALL_MANIFEST_FORMAT = "halfcycle-install-manifest/v1";
|
|
1236
|
+
function sha256Hex(bytes) {
|
|
1237
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
1238
|
+
}
|
|
1239
|
+
function canonicalJson(value) {
|
|
1240
|
+
if (Array.isArray(value))
|
|
1241
|
+
return value.map(canonicalJson);
|
|
1242
|
+
if (value !== null && typeof value === "object") {
|
|
1243
|
+
const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, canonicalJson(v)]);
|
|
1244
|
+
return Object.fromEntries(entries);
|
|
1245
|
+
}
|
|
1246
|
+
return value;
|
|
1247
|
+
}
|
|
1248
|
+
function canonicalSha256(value) {
|
|
1249
|
+
return sha256Hex(JSON.stringify(canonicalJson(value)));
|
|
1250
|
+
}
|
|
1251
|
+
function byString(a, b) {
|
|
1252
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1253
|
+
}
|
|
1254
|
+
function serializeManifest(manifest) {
|
|
1255
|
+
const settings = {
|
|
1256
|
+
...manifest.settings,
|
|
1257
|
+
hookCommands: [...manifest.settings.hookCommands].sort(byString),
|
|
1258
|
+
denyAddedSha256: [...manifest.settings.denyAddedSha256].sort(byString),
|
|
1259
|
+
eventsCreated: [...manifest.settings.eventsCreated].sort(byString)
|
|
1260
|
+
};
|
|
1261
|
+
const ordered = {
|
|
1262
|
+
...manifest,
|
|
1263
|
+
files: [...manifest.files].sort((a, b) => byString(a.path, b.path)),
|
|
1264
|
+
createdDirs: [...manifest.createdDirs].sort(byString),
|
|
1265
|
+
leftAlone: [...manifest.leftAlone].sort(byString),
|
|
1266
|
+
settings
|
|
1267
|
+
};
|
|
1268
|
+
return JSON.stringify(canonicalJson(ordered), null, 2) + "\n";
|
|
1269
|
+
}
|
|
1270
|
+
var UnreadableManifestError = class extends Error {
|
|
1271
|
+
constructor(reason) {
|
|
1272
|
+
super(reason);
|
|
1273
|
+
this.name = "UnreadableManifestError";
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
var HEX64 = /^[0-9a-f]{64}$/;
|
|
1277
|
+
function isRecord(value) {
|
|
1278
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1279
|
+
}
|
|
1280
|
+
function stringArray(value, field) {
|
|
1281
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
1282
|
+
throw new UnreadableManifestError(`"${field}" is not a list of strings`);
|
|
1283
|
+
}
|
|
1284
|
+
return value;
|
|
1285
|
+
}
|
|
1286
|
+
function hashArray(value, field) {
|
|
1287
|
+
const list = stringArray(value, field);
|
|
1288
|
+
if (!list.every((v) => HEX64.test(v)))
|
|
1289
|
+
throw new UnreadableManifestError(`"${field}" holds a value that is not a hash`);
|
|
1290
|
+
return list;
|
|
1291
|
+
}
|
|
1292
|
+
function bool(value, field) {
|
|
1293
|
+
if (typeof value !== "boolean")
|
|
1294
|
+
throw new UnreadableManifestError(`"${field}" is not true or false`);
|
|
1295
|
+
return value;
|
|
1296
|
+
}
|
|
1297
|
+
function onlyKeys(value, allowed, where) {
|
|
1298
|
+
const extra = Object.keys(value).filter((k) => !allowed.includes(k));
|
|
1299
|
+
if (extra.length > 0)
|
|
1300
|
+
throw new UnreadableManifestError(`${where} has a field this version does not know: ${extra.join(", ")}`);
|
|
1301
|
+
}
|
|
1302
|
+
function parseManifest(text) {
|
|
1303
|
+
let raw;
|
|
1304
|
+
try {
|
|
1305
|
+
raw = JSON.parse(text);
|
|
1306
|
+
} catch {
|
|
1307
|
+
throw new UnreadableManifestError("it is not valid JSON");
|
|
1308
|
+
}
|
|
1309
|
+
if (!isRecord(raw))
|
|
1310
|
+
throw new UnreadableManifestError("it is not a JSON object");
|
|
1311
|
+
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
|
|
1312
|
+
if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
|
|
1313
|
+
throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
|
|
1314
|
+
}
|
|
1315
|
+
if (!Array.isArray(raw["files"]))
|
|
1316
|
+
throw new UnreadableManifestError('"files" is not a list');
|
|
1317
|
+
const files = raw["files"].map((entry) => {
|
|
1318
|
+
if (!isRecord(entry) || typeof entry["path"] !== "string") {
|
|
1319
|
+
throw new UnreadableManifestError('a "files" entry has no path');
|
|
1320
|
+
}
|
|
1321
|
+
if (entry["perMachine"] === true) {
|
|
1322
|
+
onlyKeys(entry, ["path", "perMachine"], `the "files" entry for ${entry["path"]}`);
|
|
1323
|
+
return { path: entry["path"], perMachine: true };
|
|
1324
|
+
}
|
|
1325
|
+
onlyKeys(entry, ["path", "sha256"], `the "files" entry for ${entry["path"]}`);
|
|
1326
|
+
if (typeof entry["sha256"] !== "string" || !HEX64.test(entry["sha256"])) {
|
|
1327
|
+
throw new UnreadableManifestError(`the "files" entry for ${entry["path"]} has no valid hash`);
|
|
1328
|
+
}
|
|
1329
|
+
return { path: entry["path"], sha256: entry["sha256"] };
|
|
1330
|
+
});
|
|
1331
|
+
const s = raw["settings"];
|
|
1332
|
+
if (!isRecord(s))
|
|
1333
|
+
throw new UnreadableManifestError('"settings" is missing');
|
|
1334
|
+
onlyKeys(s, [
|
|
1335
|
+
"created",
|
|
1336
|
+
"adopted",
|
|
1337
|
+
"hookCommands",
|
|
1338
|
+
"denyAddedSha256",
|
|
1339
|
+
"schemaBefore",
|
|
1340
|
+
"eventsCreated",
|
|
1341
|
+
"hooksCreated",
|
|
1342
|
+
"permissionsCreated",
|
|
1343
|
+
"denyCreated"
|
|
1344
|
+
], '"settings"');
|
|
1345
|
+
const settings = {
|
|
1346
|
+
created: bool(s["created"], "settings.created"),
|
|
1347
|
+
adopted: bool(s["adopted"], "settings.adopted"),
|
|
1348
|
+
hookCommands: stringArray(s["hookCommands"], "settings.hookCommands"),
|
|
1349
|
+
denyAddedSha256: hashArray(s["denyAddedSha256"], "settings.denyAddedSha256"),
|
|
1350
|
+
..."schemaBefore" in s ? { schemaBefore: s["schemaBefore"] } : {},
|
|
1351
|
+
eventsCreated: stringArray(s["eventsCreated"], "settings.eventsCreated"),
|
|
1352
|
+
hooksCreated: bool(s["hooksCreated"], "settings.hooksCreated"),
|
|
1353
|
+
permissionsCreated: bool(s["permissionsCreated"], "settings.permissionsCreated"),
|
|
1354
|
+
denyCreated: bool(s["denyCreated"], "settings.denyCreated")
|
|
1355
|
+
};
|
|
1356
|
+
let mcp;
|
|
1357
|
+
if (raw["mcp"] !== void 0) {
|
|
1358
|
+
const m = raw["mcp"];
|
|
1359
|
+
if (!isRecord(m))
|
|
1360
|
+
throw new UnreadableManifestError('"mcp" is not an object');
|
|
1361
|
+
onlyKeys(m, ["created", "adopted", "mcpServersCreated", "entrySha256"], '"mcp"');
|
|
1362
|
+
if (m["entrySha256"] !== void 0 && (typeof m["entrySha256"] !== "string" || !HEX64.test(m["entrySha256"]))) {
|
|
1363
|
+
throw new UnreadableManifestError('"mcp.entrySha256" is not a hash');
|
|
1364
|
+
}
|
|
1365
|
+
mcp = {
|
|
1366
|
+
created: bool(m["created"], "mcp.created"),
|
|
1367
|
+
adopted: bool(m["adopted"], "mcp.adopted"),
|
|
1368
|
+
mcpServersCreated: bool(m["mcpServersCreated"], "mcp.mcpServersCreated"),
|
|
1369
|
+
...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
const g = raw["gitignore"];
|
|
1373
|
+
if (!isRecord(g))
|
|
1374
|
+
throw new UnreadableManifestError('"gitignore" is missing');
|
|
1375
|
+
onlyKeys(g, ["created", "appended"], '"gitignore"');
|
|
1376
|
+
const gitignore = {
|
|
1377
|
+
created: bool(g["created"], "gitignore.created"),
|
|
1378
|
+
appended: stringArray(g["appended"], "gitignore.appended")
|
|
1379
|
+
};
|
|
1380
|
+
const paths = files.map((f) => f.path);
|
|
1381
|
+
if (new Set(paths).size !== paths.length)
|
|
1382
|
+
throw new UnreadableManifestError('"files" names a path twice');
|
|
1383
|
+
const leftAlone = stringArray(raw["leftAlone"], "leftAlone");
|
|
1384
|
+
if (leftAlone.some((p) => paths.includes(p))) {
|
|
1385
|
+
throw new UnreadableManifestError('a path is in both "files" and "leftAlone"');
|
|
1386
|
+
}
|
|
1387
|
+
return {
|
|
1388
|
+
format: INSTALL_MANIFEST_FORMAT,
|
|
1389
|
+
files,
|
|
1390
|
+
createdDirs: stringArray(raw["createdDirs"], "createdDirs"),
|
|
1391
|
+
leftAlone,
|
|
1392
|
+
settings,
|
|
1393
|
+
...mcp !== void 0 ? { mcp } : {},
|
|
1394
|
+
gitignore
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
function startManifest(previous, isMember, isMemberDir) {
|
|
1398
|
+
const files = /* @__PURE__ */ new Map();
|
|
1399
|
+
for (const entry of previous?.files ?? []) {
|
|
1400
|
+
if (isMember(entry.path))
|
|
1401
|
+
files.set(entry.path, entry);
|
|
1402
|
+
}
|
|
1403
|
+
return {
|
|
1404
|
+
previous,
|
|
1405
|
+
files,
|
|
1406
|
+
collided: /* @__PURE__ */ new Set(),
|
|
1407
|
+
createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
|
|
1408
|
+
settings: previous?.settings,
|
|
1409
|
+
mcp: previous?.mcp,
|
|
1410
|
+
gitignore: previous?.gitignore
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
function recordInstalledFile(draft, path, bytes) {
|
|
1414
|
+
draft.files.set(path, { path, sha256: sha256Hex(bytes) });
|
|
1415
|
+
}
|
|
1416
|
+
function recordPerMachineFile(draft, path) {
|
|
1417
|
+
draft.files.set(path, { path, perMachine: true });
|
|
1418
|
+
}
|
|
1419
|
+
function recordCollision(draft, path) {
|
|
1420
|
+
draft.collided.add(path);
|
|
1421
|
+
}
|
|
1422
|
+
function recordCreatedDir(draft, path) {
|
|
1423
|
+
draft.createdDirs.add(path);
|
|
1424
|
+
}
|
|
1425
|
+
function recordSettings(draft, observed) {
|
|
1426
|
+
const prev = draft.settings;
|
|
1427
|
+
if (prev === void 0) {
|
|
1428
|
+
draft.settings = observed;
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
draft.settings = {
|
|
1432
|
+
created: prev.created,
|
|
1433
|
+
adopted: prev.adopted,
|
|
1434
|
+
hookCommands: union(prev.hookCommands, observed.hookCommands),
|
|
1435
|
+
denyAddedSha256: union(prev.denyAddedSha256, observed.denyAddedSha256),
|
|
1436
|
+
..."schemaBefore" in prev ? { schemaBefore: prev.schemaBefore } : "schemaBefore" in observed ? { schemaBefore: observed.schemaBefore } : {},
|
|
1437
|
+
eventsCreated: union(prev.eventsCreated, observed.eventsCreated),
|
|
1438
|
+
hooksCreated: prev.hooksCreated,
|
|
1439
|
+
permissionsCreated: prev.permissionsCreated,
|
|
1440
|
+
denyCreated: prev.denyCreated
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
function recordMcp(draft, observed) {
|
|
1444
|
+
const prev = draft.mcp;
|
|
1445
|
+
if (prev === void 0) {
|
|
1446
|
+
draft.mcp = observed;
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
const entrySha256 = observed.entrySha256 ?? prev.entrySha256;
|
|
1450
|
+
draft.mcp = {
|
|
1451
|
+
created: prev.created,
|
|
1452
|
+
adopted: prev.adopted,
|
|
1453
|
+
mcpServersCreated: prev.mcpServersCreated,
|
|
1454
|
+
...entrySha256 !== void 0 ? { entrySha256 } : {}
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
function recordGitignore(draft, created, appended) {
|
|
1458
|
+
const prev = draft.gitignore;
|
|
1459
|
+
draft.gitignore = {
|
|
1460
|
+
created: prev?.created ?? created,
|
|
1461
|
+
appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
function finishManifest(draft) {
|
|
1465
|
+
const files = [...draft.files.values()];
|
|
1466
|
+
const leftAlone = [...draft.collided].filter((p) => !draft.files.has(p));
|
|
1467
|
+
return {
|
|
1468
|
+
format: INSTALL_MANIFEST_FORMAT,
|
|
1469
|
+
files,
|
|
1470
|
+
createdDirs: [...draft.createdDirs],
|
|
1471
|
+
leftAlone,
|
|
1472
|
+
settings: draft.settings ?? {
|
|
1473
|
+
created: false,
|
|
1474
|
+
adopted: false,
|
|
1475
|
+
hookCommands: [],
|
|
1476
|
+
denyAddedSha256: [],
|
|
1477
|
+
eventsCreated: [],
|
|
1478
|
+
hooksCreated: false,
|
|
1479
|
+
permissionsCreated: false,
|
|
1480
|
+
denyCreated: false
|
|
1481
|
+
},
|
|
1482
|
+
...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
|
|
1483
|
+
gitignore: draft.gitignore ?? { created: false, appended: [] }
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
function union(a, b) {
|
|
1487
|
+
return [.../* @__PURE__ */ new Set([...a, ...b])];
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1205
1490
|
// dist/mcp-endpoint.js
|
|
1206
1491
|
var MCP_ENDPOINT_PATH = "/mcp";
|
|
1207
1492
|
function mcpEndpointUrl(origin) {
|
|
@@ -1287,8 +1572,9 @@ function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOSt
|
|
|
1287
1572
|
}
|
|
1288
1573
|
return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
|
|
1289
1574
|
}
|
|
1575
|
+
var BOOTSTRAP_STATE_REL = ".halfcycle/state.json";
|
|
1290
1576
|
function runBootstrapScan(targetRepo) {
|
|
1291
|
-
const statePath = join4(targetRepo,
|
|
1577
|
+
const statePath = join4(targetRepo, BOOTSTRAP_STATE_REL);
|
|
1292
1578
|
if (existsSync2(statePath)) {
|
|
1293
1579
|
const existing = JSON.parse(readFileSync4(statePath, "utf-8"));
|
|
1294
1580
|
return { state: existing, ran: false };
|
|
@@ -1347,22 +1633,152 @@ function isAllowlisted(targetRelPath) {
|
|
|
1347
1633
|
return normalised === p || normalised.startsWith(p + "/");
|
|
1348
1634
|
});
|
|
1349
1635
|
}
|
|
1636
|
+
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
1637
|
+
var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
|
|
1638
|
+
var CREW_ROSTER_REL = ".halfcycle/crew.json";
|
|
1639
|
+
var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
|
|
1640
|
+
var SETTINGS_REL = ".claude/settings.json";
|
|
1641
|
+
var GITIGNORE_REL = ".gitignore";
|
|
1642
|
+
function commandStubRel(name) {
|
|
1643
|
+
return `.claude/commands/${name}.md`;
|
|
1644
|
+
}
|
|
1645
|
+
var RETIRED_WRITE_SET_FILES = [];
|
|
1646
|
+
var RETIRED_HOOK_COMMANDS = [];
|
|
1647
|
+
var RETIRED_DENY_PATTERNS = [];
|
|
1648
|
+
function installerHookCommands() {
|
|
1649
|
+
const settings = JSON.parse(generateSettingsJson());
|
|
1650
|
+
return Object.values(settings.hooks ?? {}).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
|
|
1651
|
+
}
|
|
1652
|
+
function closedWriteSet() {
|
|
1653
|
+
const files = /* @__PURE__ */ new Set([
|
|
1654
|
+
...(readPluginManifest().commands ?? []).map((cmd) => commandStubRel(cmd.name)),
|
|
1655
|
+
...Object.keys(OWNED_GENERATED_HEADERS),
|
|
1656
|
+
VENDORED_BIN_REL,
|
|
1657
|
+
CREW_ROSTER_REL,
|
|
1658
|
+
BUNDLE_PIN_REL,
|
|
1659
|
+
PROJECT_IDENTITY_REL,
|
|
1660
|
+
BOOTSTRAP_STATE_REL,
|
|
1661
|
+
CAPTURED_INDEX_REL,
|
|
1662
|
+
INSTALL_MANIFEST_REL,
|
|
1663
|
+
...RETIRED_WRITE_SET_FILES
|
|
1664
|
+
]);
|
|
1665
|
+
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
|
|
1666
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
1667
|
+
for (const path of [...files, ...merged]) {
|
|
1668
|
+
for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
|
|
1669
|
+
dirs.add(dir);
|
|
1670
|
+
}
|
|
1671
|
+
const ownedDirs = new Set([...dirs].filter((dir) => `${dir}/`.startsWith(INSTALLER_OWNED_DIR)));
|
|
1672
|
+
return {
|
|
1673
|
+
files,
|
|
1674
|
+
merged,
|
|
1675
|
+
dirs,
|
|
1676
|
+
ownedDirs,
|
|
1677
|
+
hookCommands: /* @__PURE__ */ new Set([...installerHookCommands(), ...RETIRED_HOOK_COMMANDS]),
|
|
1678
|
+
denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
|
|
1679
|
+
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
function readPreviousManifest(targetRepo) {
|
|
1683
|
+
try {
|
|
1684
|
+
return parseManifest(readFileSync5(join5(targetRepo, INSTALL_MANIFEST_REL), "utf-8"));
|
|
1685
|
+
} catch {
|
|
1686
|
+
return null;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
function noteFile(draft, targetRepo, rel, outcome) {
|
|
1690
|
+
if (outcome === "collided") {
|
|
1691
|
+
recordCollision(draft, rel);
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
recordInstalledFile(draft, rel, readFileSync5(join5(targetRepo, rel)));
|
|
1695
|
+
}
|
|
1696
|
+
function recordSettingsMerge(draft, existing, generated, preexisted) {
|
|
1697
|
+
const generatedHooks = generated.hooks ?? {};
|
|
1698
|
+
const hookCommands = Object.values(generatedHooks).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
|
|
1699
|
+
const ours = new Set(hookCommands);
|
|
1700
|
+
const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : void 0;
|
|
1701
|
+
const heldOurs = Object.values(existingHooks ?? {}).some((entries) => Array.isArray(entries) && entries.some((entry) => isHalfcycleEntry(entry, ours)));
|
|
1702
|
+
const existingDeny = Array.isArray(existing.permissions?.deny) ? existing.permissions.deny : [];
|
|
1703
|
+
const schemaChanged = generated.$schema !== void 0 && existing.$schema !== generated.$schema;
|
|
1704
|
+
recordSettings(draft, {
|
|
1705
|
+
created: !preexisted,
|
|
1706
|
+
adopted: draft.previous === null && heldOurs,
|
|
1707
|
+
hookCommands,
|
|
1708
|
+
denyAddedSha256: (generated.permissions?.deny ?? []).filter((rule) => !existingDeny.includes(rule)).map((rule) => sha256Hex(rule)),
|
|
1709
|
+
...schemaChanged ? { schemaBefore: existing.$schema ?? null } : {},
|
|
1710
|
+
eventsCreated: Object.keys(generatedHooks).filter((event) => !(existingHooks && event in existingHooks)),
|
|
1711
|
+
hooksCreated: existing.hooks === void 0,
|
|
1712
|
+
permissionsCreated: existing.permissions === void 0,
|
|
1713
|
+
denyCreated: existing.permissions?.deny === void 0
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
function recordMcpMerge(draft, existingText, writtenText) {
|
|
1717
|
+
const before = existingText === null ? void 0 : JSON.parse(existingText);
|
|
1718
|
+
const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
|
|
1719
|
+
const written = JSON.parse(writtenText);
|
|
1720
|
+
recordMcp(draft, {
|
|
1721
|
+
created: existingText === null,
|
|
1722
|
+
adopted: draft.previous === null && isPlainObject(servers) && MCP_SERVER_KEY in servers,
|
|
1723
|
+
mcpServersCreated: !(isPlainObject(before) && "mcpServers" in before),
|
|
1724
|
+
entrySha256: canonicalSha256(written.mcpServers[MCP_SERVER_KEY])
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
function recordMcpAdoptionOnly(draft, existingText) {
|
|
1728
|
+
if (draft.previous !== null || draft.mcp !== void 0)
|
|
1729
|
+
return;
|
|
1730
|
+
let before;
|
|
1731
|
+
try {
|
|
1732
|
+
before = JSON.parse(existingText);
|
|
1733
|
+
} catch {
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
|
|
1737
|
+
if (!isPlainObject(servers) || !(MCP_SERVER_KEY in servers))
|
|
1738
|
+
return;
|
|
1739
|
+
recordMcp(draft, { created: false, adopted: true, mcpServersCreated: false });
|
|
1740
|
+
}
|
|
1741
|
+
function recordGitignoreReconcile(draft, before, after) {
|
|
1742
|
+
const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
|
|
1743
|
+
let appended = "";
|
|
1744
|
+
if (after !== null && after !== before) {
|
|
1745
|
+
if (before === null)
|
|
1746
|
+
appended = after;
|
|
1747
|
+
else if (after.startsWith(before))
|
|
1748
|
+
appended = after.slice(before.length);
|
|
1749
|
+
}
|
|
1750
|
+
recordGitignore(draft, before === null && after !== null, [adopted, appended]);
|
|
1751
|
+
}
|
|
1752
|
+
function assertManifestInWriteSet(manifest, writeSet) {
|
|
1753
|
+
const strays = [
|
|
1754
|
+
...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
|
|
1755
|
+
...manifest.leftAlone.filter((p) => !writeSet.files.has(p)),
|
|
1756
|
+
...manifest.createdDirs.filter((p) => !writeSet.dirs.has(p))
|
|
1757
|
+
];
|
|
1758
|
+
if (strays.length > 0) {
|
|
1759
|
+
throw new Error(`[bundle install] the install record names paths outside the write set: ${strays.join(", ")}`);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
function isPlainObject(value) {
|
|
1763
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1764
|
+
}
|
|
1350
1765
|
function readPluginManifest() {
|
|
1351
1766
|
const manifestPath = join5(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
|
|
1352
1767
|
const raw = readFileSync5(manifestPath, "utf-8");
|
|
1353
1768
|
return JSON.parse(raw);
|
|
1354
1769
|
}
|
|
1355
|
-
function copyManifestCommands(manifest, targetRepo, report) {
|
|
1356
|
-
const commandsTargetDir = join5(targetRepo, ".claude", "commands");
|
|
1770
|
+
function copyManifestCommands(manifest, targetRepo, report, draft) {
|
|
1357
1771
|
for (const cmd of manifest.commands ?? []) {
|
|
1358
1772
|
const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
|
|
1359
1773
|
if (!existsSync3(srcFile)) {
|
|
1360
1774
|
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.`);
|
|
1361
1775
|
}
|
|
1362
|
-
const
|
|
1776
|
+
const rel = commandStubRel(cmd.name);
|
|
1777
|
+
const destFile = join5(targetRepo, rel);
|
|
1363
1778
|
const content = readFileSync5(srcFile, "utf-8");
|
|
1364
|
-
const
|
|
1365
|
-
record(report,
|
|
1779
|
+
const outcome = writeCollisionSafe(destFile, targetRepo, content);
|
|
1780
|
+
record(report, outcome, rel);
|
|
1781
|
+
noteFile(draft, targetRepo, rel, outcome);
|
|
1366
1782
|
}
|
|
1367
1783
|
}
|
|
1368
1784
|
function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
|
|
@@ -1428,6 +1844,7 @@ function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, ge
|
|
|
1428
1844
|
record(report, outcome, rel);
|
|
1429
1845
|
if (replacedExisting)
|
|
1430
1846
|
report.replacedPaths.push(rel);
|
|
1847
|
+
return outcome;
|
|
1431
1848
|
}
|
|
1432
1849
|
function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
|
|
1433
1850
|
const existedBefore = existsSync3(targetAbsPath);
|
|
@@ -1435,6 +1852,7 @@ function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
|
|
|
1435
1852
|
record(report, outcome, rel);
|
|
1436
1853
|
if (outcome === "written" && existedBefore)
|
|
1437
1854
|
report.replacedPaths.push(rel);
|
|
1855
|
+
return outcome;
|
|
1438
1856
|
}
|
|
1439
1857
|
function record(report, outcome, rel) {
|
|
1440
1858
|
const bucket = {
|
|
@@ -1759,82 +2177,53 @@ printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
|
|
|
1759
2177
|
exit 0
|
|
1760
2178
|
`;
|
|
1761
2179
|
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
# permissions:
|
|
1810
|
-
# contents: read
|
|
1811
|
-
# id-token: write
|
|
1812
|
-
# steps:
|
|
1813
|
-
# - uses: actions/checkout@v4
|
|
1814
|
-
# with:
|
|
1815
|
-
# # REQUIRED. 0 = full history. The check diffs against the commit before
|
|
1816
|
-
# # HEAD (or the fork point on a branch); the default depth of 1 has
|
|
1817
|
-
# # neither. Do not lower this.
|
|
1818
|
-
# fetch-depth: 0
|
|
1819
|
-
# - uses: actions/setup-node@v4
|
|
1820
|
-
# with:
|
|
1821
|
-
# node-version: '20'
|
|
1822
|
-
# - name: Halfcycle guard CI check
|
|
1823
|
-
# # No env block, on purpose: this step holds no secret. The permissions
|
|
1824
|
-
# # above are what authenticate it.
|
|
1825
|
-
# run: node ./.halfcycle/bin/bin.bundle.mjs ci
|
|
1826
|
-
#
|
|
1827
|
-
# The job prints the diff base it used and how many files it evaluated, on every
|
|
1828
|
-
# run. If that line says 0 files on a commit that changed something, the base is
|
|
1829
|
-
# wrong \u2014 set HALFCYCLE_DIFF_BASE in an env block OF YOUR COPY of the check step,
|
|
1830
|
-
# to name it explicitly (on a GitHub push event, \${{ github.event.before }} is the
|
|
1831
|
-
# right value).
|
|
1832
|
-
#
|
|
1833
|
-
# EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
|
|
1834
|
-
# changes to it would be replaced the next time you run \`npx halfcycle\`. It is
|
|
1835
|
-
# also inert where it sits: no CI system reads this path. Copy the job above into
|
|
1836
|
-
# your own workflow and change it there.
|
|
1837
|
-
`;
|
|
2180
|
+
var CI_STANZA_KNOWN_HEADER_HASHES = [
|
|
2181
|
+
"b225800b2d699811f32ac213238cbf81c35b5e6a74114bdf55e39cd9d7e2928f",
|
|
2182
|
+
"674181735460f051af7c157222cf851fc3000fb3b8af3a10ea0bde9fe5eaba1f",
|
|
2183
|
+
"d17bde608a6fec54a63cb934a28d88acc8babc205334b5ee84d4ed6ee219d340",
|
|
2184
|
+
"85e71d3f6551977b241a28468f121ee4d1c4edef40a2c4dc1aa6f2f737ebd037"
|
|
2185
|
+
];
|
|
2186
|
+
function isTrackedAndClean(targetRepo, relPath) {
|
|
2187
|
+
try {
|
|
2188
|
+
execFileSync2("git", ["-C", targetRepo, "ls-files", "--error-unmatch", "--", relPath], {
|
|
2189
|
+
stdio: "ignore"
|
|
2190
|
+
});
|
|
2191
|
+
} catch {
|
|
2192
|
+
return false;
|
|
2193
|
+
}
|
|
2194
|
+
try {
|
|
2195
|
+
execFileSync2("git", ["-C", targetRepo, "diff", "--quiet", "HEAD", "--", relPath], {
|
|
2196
|
+
stdio: "ignore"
|
|
2197
|
+
});
|
|
2198
|
+
} catch {
|
|
2199
|
+
return false;
|
|
2200
|
+
}
|
|
2201
|
+
return true;
|
|
2202
|
+
}
|
|
2203
|
+
function removeLegacyCiStanza(targetRepo) {
|
|
2204
|
+
const rel = ".halfcycle/ci-stanza.yml";
|
|
2205
|
+
const path = join5(targetRepo, rel);
|
|
2206
|
+
if (!existsSync3(path))
|
|
2207
|
+
return "absent";
|
|
2208
|
+
let raw;
|
|
2209
|
+
try {
|
|
2210
|
+
raw = readFileSync5(path, "utf-8");
|
|
2211
|
+
} catch {
|
|
2212
|
+
return "failed";
|
|
2213
|
+
}
|
|
2214
|
+
const headerLine = raw.split("\n")[0] ?? "";
|
|
2215
|
+
const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
|
|
2216
|
+
const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
|
|
2217
|
+
if (!ours)
|
|
2218
|
+
return "kept-foreign";
|
|
2219
|
+
if (!isTrackedAndClean(targetRepo, rel))
|
|
2220
|
+
return "kept-uncommitted";
|
|
2221
|
+
try {
|
|
2222
|
+
rmSync(path);
|
|
2223
|
+
} catch {
|
|
2224
|
+
return "failed";
|
|
2225
|
+
}
|
|
2226
|
+
return "removed";
|
|
1838
2227
|
}
|
|
1839
2228
|
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
1840
2229
|
var MCP_SERVER_KEY = "halfcycle";
|
|
@@ -1876,13 +2265,81 @@ PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
|
|
1876
2265
|
|
|
1877
2266
|
${engagementResolutionShell()}
|
|
1878
2267
|
|
|
2268
|
+
# Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
|
|
2269
|
+
# shell (call it as a statement, never inside $(...)). Last assignment wins,
|
|
2270
|
+
# matching the shell's own \`.\` semantics. An optional leading \`export \` is
|
|
2271
|
+
# tolerated because a hand-edited store may use one.
|
|
2272
|
+
halfcycle_store_value() {
|
|
2273
|
+
HC_VALUE=""
|
|
2274
|
+
hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
|
|
2275
|
+
hc_v=\${hc_line#*"$2="}
|
|
2276
|
+
hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
|
|
2277
|
+
|
|
2278
|
+
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
2279
|
+
# writers use the same shq: this installer and Studio's provider), because
|
|
2280
|
+
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
2281
|
+
# would execute its own remainder. This reader greps rather than sources, so it
|
|
2282
|
+
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
2283
|
+
# sourcing consumer gets, or one file has two answers.
|
|
2284
|
+
case $hc_v in
|
|
2285
|
+
'"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
|
|
2286
|
+
"'"*"'")
|
|
2287
|
+
hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
|
|
2288
|
+
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
2289
|
+
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
2290
|
+
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
2291
|
+
# no shell quoting is in the way:
|
|
2292
|
+
#
|
|
2293
|
+
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
2294
|
+
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
2295
|
+
#
|
|
2296
|
+
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
2297
|
+
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
2298
|
+
# and the token then travels with four stray characters in it. The bracket
|
|
2299
|
+
# expression makes the backslash literal by a construction POSIX does define.
|
|
2300
|
+
hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
|
|
2301
|
+
esac
|
|
2302
|
+
HC_VALUE=$hc_v
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
# The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
|
|
2306
|
+
# a space or a control character anywhere. That refusal is the load-bearing part:
|
|
2307
|
+
# a URL parser drops tabs and newlines before it reads the host, so
|
|
2308
|
+
# "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
|
|
2309
|
+
# line-by-line reading here would see only "https://ours". The host ends at the
|
|
2310
|
+
# first / ? # or backslash, which is where a URL parser ends it for http(s).
|
|
2311
|
+
halfcycle_origin() {
|
|
2312
|
+
hc_u=$1
|
|
2313
|
+
if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
|
|
2314
|
+
return 0
|
|
2315
|
+
fi
|
|
2316
|
+
printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
|
|
2317
|
+
}
|
|
2318
|
+
|
|
1879
2319
|
# HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
|
|
1880
2320
|
# browser and no per-user store exports the credential, and a stale store on a
|
|
1881
2321
|
# long-lived runner must not silently win over it. Same order, and the same reason,
|
|
1882
|
-
# as the CLI's own resolver (\`resolve-credential.ts\`).
|
|
2322
|
+
# as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
|
|
2323
|
+
# to is NOT taken from the environment: it is read from this machine's store in
|
|
2324
|
+
# both arms, so a token supplied this way still goes only where this machine was
|
|
2325
|
+
# set up to send it.
|
|
2326
|
+
# The pin names an id that is not a project id. Re-running the installer against
|
|
2327
|
+
# that pin refuses it too, so the one remedy that works is a fresh pin.
|
|
2328
|
+
halfcycle_bad_id_message() {
|
|
2329
|
+
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
|
|
2330
|
+
echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
|
|
2331
|
+
echo "halfcycle: is then set up as a new Halfcycle project." >&2
|
|
2332
|
+
}
|
|
2333
|
+
|
|
1883
2334
|
TOKEN=\${HALFCYCLE_TOKEN:-}
|
|
2335
|
+
MCP_URL=""
|
|
1884
2336
|
|
|
1885
|
-
if [ -
|
|
2337
|
+
if [ -n "$TOKEN" ]; then
|
|
2338
|
+
if halfcycle_env_file "$PROJECT_ROOT"; then
|
|
2339
|
+
halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
|
|
2340
|
+
MCP_URL=$HC_VALUE
|
|
2341
|
+
fi
|
|
2342
|
+
else
|
|
1886
2343
|
if ! halfcycle_env_file "$PROJECT_ROOT"; then
|
|
1887
2344
|
case $HALFCYCLE_ENV_PROBLEM in
|
|
1888
2345
|
no-pin)
|
|
@@ -1891,6 +2348,8 @@ if [ -z "$TOKEN" ]; then
|
|
|
1891
2348
|
no-id)
|
|
1892
2349
|
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
|
|
1893
2350
|
echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
|
|
2351
|
+
bad-id)
|
|
2352
|
+
halfcycle_bad_id_message ;;
|
|
1894
2353
|
no-home)
|
|
1895
2354
|
echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
|
|
1896
2355
|
*)
|
|
@@ -1913,42 +2372,48 @@ if [ -z "$TOKEN" ]; then
|
|
|
1913
2372
|
fi
|
|
1914
2373
|
ENV_FILE="$HALFCYCLE_ENV_FILE"
|
|
1915
2374
|
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
LINE=$(grep '^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}HALFCYCLE_TOKEN=' "$ENV_FILE" | tail -n 1)
|
|
1919
|
-
TOKEN=\${LINE#*HALFCYCLE_TOKEN=}
|
|
1920
|
-
TOKEN=$(printf '%s' "$TOKEN" | tr -d '\\r')
|
|
1921
|
-
|
|
1922
|
-
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
1923
|
-
# writers use the same shq: this installer and Studio's provider), because
|
|
1924
|
-
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
1925
|
-
# would execute its own remainder. This reader greps rather than sources, so it
|
|
1926
|
-
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
1927
|
-
# sourcing consumer gets, or one file has two answers.
|
|
1928
|
-
case $TOKEN in
|
|
1929
|
-
'"'*'"') TOKEN=\${TOKEN#'"'}; TOKEN=\${TOKEN%'"'} ;;
|
|
1930
|
-
"'"*"'")
|
|
1931
|
-
TOKEN=\${TOKEN#"'"}; TOKEN=\${TOKEN%"'"}
|
|
1932
|
-
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
1933
|
-
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
1934
|
-
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
1935
|
-
# no shell quoting is in the way:
|
|
1936
|
-
#
|
|
1937
|
-
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
1938
|
-
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
1939
|
-
#
|
|
1940
|
-
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
1941
|
-
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
1942
|
-
# and the token then travels with four stray characters in it. The bracket
|
|
1943
|
-
# expression makes the backslash literal by a construction POSIX does define.
|
|
1944
|
-
TOKEN=$(printf '%s' "$TOKEN" | sed "s/'[\\]''/'/g") ;;
|
|
1945
|
-
esac
|
|
1946
|
-
|
|
2375
|
+
halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
|
|
2376
|
+
TOKEN=$HC_VALUE
|
|
1947
2377
|
if [ -z "$TOKEN" ]; then
|
|
1948
2378
|
echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
|
|
1949
2379
|
echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
|
|
1950
2380
|
exit 1
|
|
1951
2381
|
fi
|
|
2382
|
+
halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
|
|
2383
|
+
MCP_URL=$HC_VALUE
|
|
2384
|
+
fi
|
|
2385
|
+
|
|
2386
|
+
# THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
|
|
2387
|
+
# file: anyone who can land a commit can change the server's url, and this script
|
|
2388
|
+
# would then hand the credential to whatever it names. Claude Code tells the helper
|
|
2389
|
+
# which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
|
|
2390
|
+
# machine was set up against is recorded OUTSIDE the repository, beside the
|
|
2391
|
+
# credential. The two must share scheme, host and port, or nothing is printed.
|
|
2392
|
+
# No url at all is a refusal too: sending the credential without knowing where it
|
|
2393
|
+
# is going is the thing this check exists to stop.
|
|
2394
|
+
REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
|
|
2395
|
+
if [ -z "$REQUESTED" ]; then
|
|
2396
|
+
echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
|
|
2397
|
+
echo "halfcycle: update Claude Code, then reconnect." >&2
|
|
2398
|
+
exit 1
|
|
2399
|
+
fi
|
|
2400
|
+
if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
|
|
2401
|
+
halfcycle_bad_id_message
|
|
2402
|
+
exit 1
|
|
2403
|
+
fi
|
|
2404
|
+
if [ -z "$MCP_URL" ]; then
|
|
2405
|
+
echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
|
|
2406
|
+
echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
|
|
2407
|
+
exit 1
|
|
2408
|
+
fi
|
|
2409
|
+
WANT=$(halfcycle_origin "$MCP_URL")
|
|
2410
|
+
GOT=$(halfcycle_origin "$REQUESTED")
|
|
2411
|
+
if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
|
|
2412
|
+
SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
|
|
2413
|
+
echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
|
|
2414
|
+
echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
|
|
2415
|
+
echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
|
|
2416
|
+
exit 1
|
|
1952
2417
|
fi
|
|
1953
2418
|
|
|
1954
2419
|
# JSON-escape: backslash first, then double quote. A token carrying either would
|
|
@@ -1964,7 +2429,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
|
|
|
1964
2429
|
if (existing !== null) {
|
|
1965
2430
|
const parsed = JSON.parse(existing);
|
|
1966
2431
|
if (parsed !== null && typeof parsed === "object") {
|
|
1967
|
-
base = {
|
|
2432
|
+
base = { ...parsed };
|
|
1968
2433
|
}
|
|
1969
2434
|
}
|
|
1970
2435
|
if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
|
|
@@ -1978,13 +2443,33 @@ function generateMcpRegistration(existing, mcpOrigin) {
|
|
|
1978
2443
|
return JSON.stringify(base, null, 2) + "\n";
|
|
1979
2444
|
}
|
|
1980
2445
|
var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
|
|
2446
|
+
var GITIGNORE_HEADER = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
|
|
2447
|
+
function adoptOlderGitignoreBlock(before) {
|
|
2448
|
+
const claimable = /* @__PURE__ */ new Set([...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]);
|
|
2449
|
+
const claimed = [];
|
|
2450
|
+
let underHeader = false;
|
|
2451
|
+
const lines = before.split("\n");
|
|
2452
|
+
for (const [i, line] of lines.entries()) {
|
|
2453
|
+
if (line === GITIGNORE_HEADER) {
|
|
2454
|
+
if (i > 0 && lines[i - 1] === "")
|
|
2455
|
+
claimed.push("");
|
|
2456
|
+
claimed.push(line);
|
|
2457
|
+
underHeader = true;
|
|
2458
|
+
} else if (line.trim() === "") {
|
|
2459
|
+
underHeader = false;
|
|
2460
|
+
} else if (underHeader && claimable.has(line)) {
|
|
2461
|
+
claimed.push(line);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
return claimed.length > 0 ? claimed.join("\n") + "\n" : "";
|
|
2465
|
+
}
|
|
1981
2466
|
function gitignoreCovers(content) {
|
|
1982
2467
|
const lines = new Set(content.split("\n").map((l) => l.trim()));
|
|
1983
2468
|
return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
|
|
1984
2469
|
}
|
|
1985
2470
|
function reconcileGitignore(targetRepo) {
|
|
1986
|
-
const gitignorePath = join5(targetRepo,
|
|
1987
|
-
const header =
|
|
2471
|
+
const gitignorePath = join5(targetRepo, GITIGNORE_REL);
|
|
2472
|
+
const header = GITIGNORE_HEADER;
|
|
1988
2473
|
try {
|
|
1989
2474
|
if (!existsSync3(gitignorePath)) {
|
|
1990
2475
|
const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
|
|
@@ -2005,26 +2490,31 @@ ${header}
|
|
|
2005
2490
|
return "failed";
|
|
2006
2491
|
}
|
|
2007
2492
|
}
|
|
2008
|
-
function
|
|
2493
|
+
function previousPinForEngagement(targetRepoRoot, engagementId) {
|
|
2009
2494
|
try {
|
|
2010
2495
|
const existing = readBundlePin(targetRepoRoot);
|
|
2011
2496
|
if (existing === null || existing.engagementId !== engagementId)
|
|
2012
2497
|
return void 0;
|
|
2013
|
-
return existing
|
|
2498
|
+
return existing;
|
|
2014
2499
|
} catch {
|
|
2015
2500
|
return void 0;
|
|
2016
2501
|
}
|
|
2017
2502
|
}
|
|
2503
|
+
function nonEmpty(value) {
|
|
2504
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
2505
|
+
}
|
|
2018
2506
|
function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
|
|
2019
|
-
const
|
|
2507
|
+
const previous = previousPinForEngagement(targetRepoRoot, engagementId);
|
|
2508
|
+
const carried = nonEmpty(accountId) ?? nonEmpty(previous?.accountId);
|
|
2509
|
+
const installedAt = nonEmpty(previous?.installedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2020
2510
|
const pin = {
|
|
2021
2511
|
version,
|
|
2022
2512
|
engagementId,
|
|
2023
2513
|
engagementType,
|
|
2024
|
-
installedAt
|
|
2025
|
-
...carried !== void 0
|
|
2514
|
+
installedAt,
|
|
2515
|
+
...carried !== void 0 ? { accountId: carried } : {}
|
|
2026
2516
|
};
|
|
2027
|
-
const pinPath = join5(targetRepoRoot,
|
|
2517
|
+
const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
|
|
2028
2518
|
writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
|
|
2029
2519
|
}
|
|
2030
2520
|
function writeCrewRoster(targetRepoRoot, report) {
|
|
@@ -2034,11 +2524,11 @@ function writeCrewRoster(targetRepoRoot, report) {
|
|
|
2034
2524
|
};
|
|
2035
2525
|
const rendered = `${JSON.stringify(doc, null, 2)}
|
|
2036
2526
|
`;
|
|
2037
|
-
const crewPath = join5(targetRepoRoot,
|
|
2038
|
-
recordOwned(report, crewPath, targetRepoRoot, rendered,
|
|
2527
|
+
const crewPath = join5(targetRepoRoot, CREW_ROSTER_REL);
|
|
2528
|
+
return recordOwned(report, crewPath, targetRepoRoot, rendered, CREW_ROSTER_REL);
|
|
2039
2529
|
}
|
|
2040
2530
|
function readBundlePin(targetRepoRoot) {
|
|
2041
|
-
const pinPath = join5(targetRepoRoot,
|
|
2531
|
+
const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
|
|
2042
2532
|
if (!existsSync3(pinPath))
|
|
2043
2533
|
return null;
|
|
2044
2534
|
return JSON.parse(readFileSync5(pinPath, "utf-8"));
|
|
@@ -2048,6 +2538,7 @@ function readPinnedEngagement(targetRepoRoot, home) {
|
|
|
2048
2538
|
const engagementId = pin?.engagementId;
|
|
2049
2539
|
if (engagementId === void 0 || engagementId === "")
|
|
2050
2540
|
return null;
|
|
2541
|
+
assertEngagementId(engagementId);
|
|
2051
2542
|
const stored = readEngagementEnv(engagementId, home);
|
|
2052
2543
|
const fromStore = stored === null ? void 0 : toCredential(stored);
|
|
2053
2544
|
if (fromStore !== void 0) {
|
|
@@ -2099,7 +2590,7 @@ function checkDrift(targetRepoRoot) {
|
|
|
2099
2590
|
return { drifted: installed !== current, installed, current };
|
|
2100
2591
|
}
|
|
2101
2592
|
function writeProjectIdentity(targetRepo) {
|
|
2102
|
-
const path = join5(targetRepo,
|
|
2593
|
+
const path = join5(targetRepo, PROJECT_IDENTITY_REL);
|
|
2103
2594
|
const { identity } = mintOrReadIdentity(targetRepo);
|
|
2104
2595
|
const serialized = JSON.stringify(identity, null, 2) + "\n";
|
|
2105
2596
|
if (existsSync3(path) && readFileSync5(path, "utf-8") === serialized)
|
|
@@ -2178,6 +2669,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
|
|
|
2178
2669
|
}
|
|
2179
2670
|
async function install(options) {
|
|
2180
2671
|
const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
|
|
2672
|
+
assertEngagementId(engagementId);
|
|
2181
2673
|
if (!existsSync3(targetRepo)) {
|
|
2182
2674
|
throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
|
|
2183
2675
|
}
|
|
@@ -2189,22 +2681,30 @@ async function install(options) {
|
|
|
2189
2681
|
collidedPaths: [],
|
|
2190
2682
|
replacedPaths: []
|
|
2191
2683
|
};
|
|
2192
|
-
|
|
2193
|
-
const
|
|
2194
|
-
const
|
|
2684
|
+
const writeSet = closedWriteSet();
|
|
2685
|
+
const draft = startManifest(readPreviousManifest(targetRepo), (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
|
|
2686
|
+
const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
|
|
2687
|
+
copyManifestCommands(manifest, targetRepo, report, draft);
|
|
2688
|
+
const capturedManifest = join5(targetRepo, CAPTURED_INDEX_REL);
|
|
2689
|
+
const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
|
|
2195
2690
|
if (!existsSync3(capturedManifest)) {
|
|
2196
|
-
const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
|
|
2197
2691
|
writeAllowlisted(capturedManifest, targetRepo, readFileSync5(srcManifest, "utf-8"), report.writtenPaths);
|
|
2692
|
+
noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "written");
|
|
2198
2693
|
} else {
|
|
2199
|
-
report.skippedPaths.push(
|
|
2694
|
+
report.skippedPaths.push(CAPTURED_INDEX_REL);
|
|
2695
|
+
if (readFileSync5(capturedManifest).equals(readFileSync5(srcManifest))) {
|
|
2696
|
+
noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "skipped");
|
|
2697
|
+
}
|
|
2200
2698
|
}
|
|
2201
2699
|
const vendoredBinSrc = resolveVendoredBinary();
|
|
2202
2700
|
const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
|
|
2203
|
-
recordOwned(report, vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8"),
|
|
2204
|
-
|
|
2701
|
+
const vendoredOutcome = recordOwned(report, vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8"), VENDORED_BIN_REL);
|
|
2702
|
+
noteFile(draft, targetRepo, VENDORED_BIN_REL, vendoredOutcome);
|
|
2703
|
+
const settingsPath = join5(targetRepo, SETTINGS_REL);
|
|
2205
2704
|
const settingsPreexisted = existsSync3(settingsPath);
|
|
2206
2705
|
const generatedSettings = JSON.parse(generateSettingsJson());
|
|
2207
2706
|
const existingSettings = settingsPreexisted ? JSON.parse(readFileSync5(settingsPath, "utf-8")) : {};
|
|
2707
|
+
recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted);
|
|
2208
2708
|
const mergedSettings = mergeSettings(existingSettings, generatedSettings);
|
|
2209
2709
|
const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
|
|
2210
2710
|
mkdirSync4(dirname2(settingsPath), { recursive: true });
|
|
@@ -2218,16 +2718,33 @@ async function install(options) {
|
|
|
2218
2718
|
]) {
|
|
2219
2719
|
const rel = `.claude/hooks/${name}`;
|
|
2220
2720
|
const hookPath = join5(targetRepo, ".claude", "hooks", name);
|
|
2221
|
-
recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
|
|
2721
|
+
const outcome = recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
|
|
2722
|
+
noteFile(draft, targetRepo, rel, outcome);
|
|
2723
|
+
}
|
|
2724
|
+
switch (removeLegacyCiStanza(targetRepo)) {
|
|
2725
|
+
case "removed":
|
|
2726
|
+
report.writtenPaths.push(".halfcycle/ci-stanza.yml (REMOVED \u2014 Halfcycle no longer generates this file)");
|
|
2727
|
+
break;
|
|
2728
|
+
case "kept-foreign":
|
|
2729
|
+
report.skippedPaths.push(".halfcycle/ci-stanza.yml (KEPT \u2014 this file was not generated by Halfcycle, so it was left alone)");
|
|
2730
|
+
break;
|
|
2731
|
+
case "kept-uncommitted":
|
|
2732
|
+
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)");
|
|
2733
|
+
break;
|
|
2734
|
+
case "failed":
|
|
2735
|
+
report.skippedPaths.push(".halfcycle/ci-stanza.yml (could not be removed \u2014 DELETE IT BY HAND: Halfcycle no longer uses this file)");
|
|
2736
|
+
break;
|
|
2737
|
+
case "absent":
|
|
2738
|
+
break;
|
|
2222
2739
|
}
|
|
2223
|
-
const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
|
|
2224
|
-
recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
|
|
2225
2740
|
if (credential) {
|
|
2226
2741
|
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
2227
|
-
recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
|
|
2742
|
+
const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
|
|
2743
|
+
noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
|
|
2228
2744
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
2229
2745
|
const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
|
|
2230
2746
|
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
2747
|
+
recordMcpMerge(draft, existingMcp, mcpContent);
|
|
2231
2748
|
if (existingMcp === null) {
|
|
2232
2749
|
writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
|
|
2233
2750
|
} else if (existingMcp !== mcpContent) {
|
|
@@ -2237,14 +2754,22 @@ async function install(options) {
|
|
|
2237
2754
|
}
|
|
2238
2755
|
} else {
|
|
2239
2756
|
report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
|
|
2757
|
+
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
2758
|
+
if (existsSync3(mcpPath))
|
|
2759
|
+
recordMcpAdoptionOnly(draft, readFileSync5(mcpPath, "utf-8"));
|
|
2240
2760
|
}
|
|
2761
|
+
const gitignorePath = join5(targetRepo, GITIGNORE_REL);
|
|
2762
|
+
const gitignoreBefore = existsSync3(gitignorePath) ? readFileSync5(gitignorePath, "utf-8") : null;
|
|
2241
2763
|
const gitignoreOutcome = reconcileGitignore(targetRepo);
|
|
2764
|
+
recordGitignoreReconcile(draft, gitignoreBefore, existsSync3(gitignorePath) ? readFileSync5(gitignorePath, "utf-8") : null);
|
|
2242
2765
|
if (gitignoreOutcome === "failed") {
|
|
2243
2766
|
report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
|
|
2244
2767
|
} else {
|
|
2245
2768
|
record(report, gitignoreOutcome, ".gitignore");
|
|
2246
2769
|
}
|
|
2247
|
-
|
|
2770
|
+
const identityOutcome = writeProjectIdentity(targetRepo);
|
|
2771
|
+
record(report, identityOutcome, PROJECT_IDENTITY_REL);
|
|
2772
|
+
noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
|
|
2248
2773
|
const credentialPath = engagementEnvPath(engagementId, home);
|
|
2249
2774
|
if (credential) {
|
|
2250
2775
|
record(report, writeEngagementCredential(credential, engagementId, home), credentialPath);
|
|
@@ -2270,8 +2795,18 @@ async function install(options) {
|
|
|
2270
2795
|
break;
|
|
2271
2796
|
}
|
|
2272
2797
|
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
|
|
2273
|
-
|
|
2798
|
+
noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
|
|
2799
|
+
noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
|
|
2274
2800
|
const scanResult = runBootstrapScan(targetRepo);
|
|
2801
|
+
if (existsSync3(join5(targetRepo, BOOTSTRAP_STATE_REL)))
|
|
2802
|
+
recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
|
|
2803
|
+
for (const dir of writeSet.dirs) {
|
|
2804
|
+
if (!dirsBefore.has(dir) && existsSync3(join5(targetRepo, dir)))
|
|
2805
|
+
recordCreatedDir(draft, dir);
|
|
2806
|
+
}
|
|
2807
|
+
const installManifest = finishManifest(draft);
|
|
2808
|
+
assertManifestInWriteSet(installManifest, writeSet);
|
|
2809
|
+
recordOwned(report, join5(targetRepo, INSTALL_MANIFEST_REL), targetRepo, serializeManifest(installManifest), INSTALL_MANIFEST_REL);
|
|
2275
2810
|
return {
|
|
2276
2811
|
version: manifest.version,
|
|
2277
2812
|
writtenPaths: report.writtenPaths,
|
|
@@ -3536,11 +4071,11 @@ function projectSeedGuard(fired, includeExplanation) {
|
|
|
3536
4071
|
|
|
3537
4072
|
// dist/build-record/sources.js
|
|
3538
4073
|
import { readFileSync as readFileSync6, existsSync as existsSync4 } from "node:fs";
|
|
3539
|
-
import { createHash } from "node:crypto";
|
|
4074
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
3540
4075
|
import { join as join8 } from "node:path";
|
|
3541
4076
|
|
|
3542
4077
|
// dist/build-record/close-record.js
|
|
3543
|
-
import { execFileSync as
|
|
4078
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
3544
4079
|
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
3545
4080
|
import { dirname as dirname3, join as join6 } from "node:path";
|
|
3546
4081
|
var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
|
|
@@ -3557,11 +4092,11 @@ function closeRecordPath(repoRoot, phase) {
|
|
|
3557
4092
|
}
|
|
3558
4093
|
function resolveCloseAtHead(repoRoot) {
|
|
3559
4094
|
try {
|
|
3560
|
-
const closeCommit =
|
|
4095
|
+
const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
|
|
3561
4096
|
encoding: "utf-8",
|
|
3562
4097
|
stdio: ["ignore", "pipe", "ignore"]
|
|
3563
4098
|
}).trim();
|
|
3564
|
-
const closedDate =
|
|
4099
|
+
const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
|
|
3565
4100
|
encoding: "utf-8",
|
|
3566
4101
|
stdio: ["ignore", "pipe", "ignore"]
|
|
3567
4102
|
}).trim();
|
|
@@ -3785,7 +4320,7 @@ function syntheticRunId(record2) {
|
|
|
3785
4320
|
record2["runType"],
|
|
3786
4321
|
record2["phase"]
|
|
3787
4322
|
].join("|");
|
|
3788
|
-
const hex =
|
|
4323
|
+
const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
|
|
3789
4324
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
3790
4325
|
}
|
|
3791
4326
|
function readGuardEvalLog(logDir, phaseId) {
|
|
@@ -3957,6 +4492,8 @@ var OPEN_PHASE_ENV_KEYS = [
|
|
|
3957
4492
|
];
|
|
3958
4493
|
function readRepoCredential(repoRoot, env = process.env, home) {
|
|
3959
4494
|
const pinned = readPinnedEngagementId(repoRoot);
|
|
4495
|
+
if (pinned !== null)
|
|
4496
|
+
assertEngagementId(pinned);
|
|
3960
4497
|
const fromStore = pinned === null ? null : readEngagementEnv(pinned, home);
|
|
3961
4498
|
const looked = pinned === null ? `${join9(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
|
|
3962
4499
|
const values = {};
|
|
@@ -3983,6 +4520,9 @@ function readPinnedEngagementId(repoRoot) {
|
|
|
3983
4520
|
}
|
|
3984
4521
|
}
|
|
3985
4522
|
function writePhaseStamp(engagementId, phase, home) {
|
|
4523
|
+
if (!isEngagementId(engagementId)) {
|
|
4524
|
+
return { path: "(none \u2014 no valid project id)", reason: new InvalidEngagementIdError(engagementId).message };
|
|
4525
|
+
}
|
|
3986
4526
|
const path = engagementEnvPath(engagementId, home);
|
|
3987
4527
|
if (phase !== null && !isValidPhaseIdentity(phase)) {
|
|
3988
4528
|
return { path, reason: new InvalidPhaseIdentityError(phase).message };
|
|
@@ -4091,115 +4631,13 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
|
|
|
4091
4631
|
return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
|
|
4092
4632
|
}
|
|
4093
4633
|
|
|
4094
|
-
// dist/ci-bind.js
|
|
4095
|
-
function parseCiRepositoryArg(arg) {
|
|
4096
|
-
const parts = arg.split("/");
|
|
4097
|
-
if (parts.length !== 2)
|
|
4098
|
-
return null;
|
|
4099
|
-
const [owner, repo] = parts;
|
|
4100
|
-
if (owner === void 0 || owner.trim() === "")
|
|
4101
|
-
return null;
|
|
4102
|
-
if (repo === void 0 || repo.trim() === "")
|
|
4103
|
-
return null;
|
|
4104
|
-
return { owner, repo };
|
|
4105
|
-
}
|
|
4106
|
-
var CiBindRefused = class extends Error {
|
|
4107
|
-
status;
|
|
4108
|
-
constructor(status, message) {
|
|
4109
|
-
super(message);
|
|
4110
|
-
this.status = status;
|
|
4111
|
-
this.name = "CiBindRefused";
|
|
4112
|
-
}
|
|
4113
|
-
/** Is this the ONE arm signing in again can fix — see this file's header. */
|
|
4114
|
-
get authRefused() {
|
|
4115
|
-
return this.status === 401;
|
|
4116
|
-
}
|
|
4117
|
-
/**
|
|
4118
|
-
* Is this "ours to fix, try again", not "yours to fix"? The question is not
|
|
4119
|
-
* mechanical (did a handler run) — it is what a developer reading the message
|
|
4120
|
-
* does next. `502`/`504` are the reverse proxy in front of the published origin
|
|
4121
|
-
* answering for a control plane that is down or slow, with an HTML body this
|
|
4122
|
-
* file's own `requestBinding` cannot parse, so the message would otherwise
|
|
4123
|
-
* degrade to a bare "the URL returned 502". `503` is `resolveAccount`'s own
|
|
4124
|
-
* fail-closed refusal when it could not reach the token store — the caller's
|
|
4125
|
-
* credential was never actually checked, and its posture is emphatic this is NOT
|
|
4126
|
-
* a "no". **`500` belongs beside them, not with the 4xx arms.** It is an
|
|
4127
|
-
* unhandled throw: nothing decided the request's merits, and the bind may even
|
|
4128
|
-
* have half-happened if the throw landed after a commit. On this route the
|
|
4129
|
-
* body is the same degraded "the URL returned 500" a 502/504 produces — an
|
|
4130
|
-
* internal path echoed back with no advice — so `refused (500)` would send a
|
|
4131
|
-
* developer to re-check ownership, the repository name and whether it is bound
|
|
4132
|
-
* elsewhere: every 4xx question, none of them this status's actual cause.
|
|
4133
|
-
*
|
|
4134
|
-
* So this is every `5xx`, not an enumerated set of three — the boundary is
|
|
4135
|
-
* "did any handler decide yes or no", and a 4xx is the only family that ever did.
|
|
4136
|
-
*/
|
|
4137
|
-
get unavailable() {
|
|
4138
|
-
return this.status >= 500 && this.status < 600;
|
|
4139
|
-
}
|
|
4140
|
-
};
|
|
4141
|
-
async function requestBinding(method, serviceUrl, engagementId, repository, credential, noun) {
|
|
4142
|
-
const url = `${serviceUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/ci-bindings/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`;
|
|
4143
|
-
let res;
|
|
4144
|
-
try {
|
|
4145
|
-
res = await fetch(url, { method, headers: { authorization: `Bearer ${credential}` } });
|
|
4146
|
-
} catch (err) {
|
|
4147
|
-
throw new Error(`[halfcycle] Could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}. Nothing was ${noun}.`);
|
|
4148
|
-
}
|
|
4149
|
-
if (res.status === 204)
|
|
4150
|
-
return;
|
|
4151
|
-
const body = await res.json().catch(() => null);
|
|
4152
|
-
const message = typeof body?.message === "string" ? body.message : `${url} returned ${res.status}.`;
|
|
4153
|
-
throw new CiBindRefused(res.status, message);
|
|
4154
|
-
}
|
|
4155
|
-
async function setCiBinding(action, serviceUrl, engagementId, repository, deps = {}) {
|
|
4156
|
-
const noun = action === "bind" ? "bound" : "unbound";
|
|
4157
|
-
const signInDeps = {
|
|
4158
|
-
...deps,
|
|
4159
|
-
reason: deps.reason ?? (action === "bind" ? "trust this repository for this engagement's CI" : "stop trusting this repository for this engagement's CI")
|
|
4160
|
-
};
|
|
4161
|
-
let credential = await obtainCredential(serviceUrl, signInDeps);
|
|
4162
|
-
for (; ; ) {
|
|
4163
|
-
try {
|
|
4164
|
-
await requestBinding(action === "bind" ? "PUT" : "DELETE", serviceUrl, engagementId, repository, credential.credential, noun);
|
|
4165
|
-
return;
|
|
4166
|
-
} catch (err) {
|
|
4167
|
-
if (!(err instanceof CiBindRefused) || !err.authRefused)
|
|
4168
|
-
throw err;
|
|
4169
|
-
const replacement = await replaceRefusedCredential(serviceUrl, credential, signInDeps);
|
|
4170
|
-
if (replacement === null) {
|
|
4171
|
-
throw new SignInRefused(`ci-${action}-${err.status}`, `${err.message} ${refusedCredentialRemedy(credential.source)} Nothing was ${noun}.`);
|
|
4172
|
-
}
|
|
4173
|
-
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
4174
|
-
`);
|
|
4175
|
-
credential = replacement;
|
|
4176
|
-
}
|
|
4177
|
-
}
|
|
4178
|
-
}
|
|
4179
|
-
function bindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
|
|
4180
|
-
return setCiBinding("bind", serviceUrl, engagementId, repository, deps);
|
|
4181
|
-
}
|
|
4182
|
-
function unbindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
|
|
4183
|
-
return setCiBinding("unbind", serviceUrl, engagementId, repository, deps);
|
|
4184
|
-
}
|
|
4185
|
-
|
|
4186
4634
|
// dist/cli-contract.js
|
|
4187
|
-
var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "
|
|
4635
|
+
var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "uninstall"];
|
|
4188
4636
|
var CONTRACTS = [
|
|
4189
|
-
// `install`, `check-drift`, `build-record` and `ci` take positionals and no
|
|
4190
|
-
// required flags. They are declared so the verb set has ONE home — a remedy
|
|
4191
|
-
// naming a verb this CLI does not have is the same defect as one missing a flag.
|
|
4192
|
-
//
|
|
4193
|
-
// `ci`'s two forms (`ci bind <owner>/<repo>`, `ci unbind <owner>/<repo>`) take a
|
|
4194
|
-
// sub-action and a repository as POSITIONALS, not flags — this table answers only
|
|
4195
|
-
// *which flags must an invocation carry*, and neither form has one (T-11,
|
|
4196
|
-
// `ci-oidc-token-exchange`). The route it calls declares no wire shape either
|
|
4197
|
-
// (path segments in, `204` out — T-05), so there is no schema for an argument
|
|
4198
|
-
// here to disagree with.
|
|
4199
4637
|
{ verb: "install", required: [], conditional: [] },
|
|
4200
4638
|
{ verb: "check-drift", required: [], conditional: [] },
|
|
4201
4639
|
{ verb: "build-record", required: [], conditional: [] },
|
|
4202
|
-
{ verb: "
|
|
4640
|
+
{ verb: "uninstall", required: [], conditional: [] },
|
|
4203
4641
|
{
|
|
4204
4642
|
verb: "open-phase",
|
|
4205
4643
|
required: [
|
|
@@ -4306,10 +4744,618 @@ var PLACEHOLDERS = {
|
|
|
4306
4744
|
"--evidence": '"\u2026"'
|
|
4307
4745
|
};
|
|
4308
4746
|
|
|
4747
|
+
// dist/uninstall.js
|
|
4748
|
+
import { lstatSync, readFileSync as readFileSync8, readdirSync, rmSync as rmSync2, rmdirSync, writeFileSync as writeFileSync7 } from "node:fs";
|
|
4749
|
+
import { homedir as homedir2 } from "node:os";
|
|
4750
|
+
import { dirname as dirname4, join as join10, relative as relative3, sep } from "node:path";
|
|
4751
|
+
var REMOVE_CREDENTIAL_FLAG = "--remove-credential";
|
|
4752
|
+
function parseUninstallArgs(args2) {
|
|
4753
|
+
let removeCredential = false;
|
|
4754
|
+
for (const arg of args2) {
|
|
4755
|
+
if (arg === REMOVE_CREDENTIAL_FLAG) {
|
|
4756
|
+
removeCredential = true;
|
|
4757
|
+
continue;
|
|
4758
|
+
}
|
|
4759
|
+
return {
|
|
4760
|
+
error: arg.startsWith("-") ? `unknown flag "${arg}" \u2014 the one flag is ${REMOVE_CREDENTIAL_FLAG}. Nothing was changed.` : `unexpected argument "${arg}" \u2014 run it in the project directory, with no path. Nothing was changed.`
|
|
4761
|
+
};
|
|
4762
|
+
}
|
|
4763
|
+
return { removeCredential };
|
|
4764
|
+
}
|
|
4765
|
+
function lexists(path) {
|
|
4766
|
+
try {
|
|
4767
|
+
lstatSync(path);
|
|
4768
|
+
return true;
|
|
4769
|
+
} catch {
|
|
4770
|
+
return false;
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
function symlinkOnPath(root, rel) {
|
|
4774
|
+
let current = root;
|
|
4775
|
+
for (const segment of rel.split("/")) {
|
|
4776
|
+
current = join10(current, segment);
|
|
4777
|
+
try {
|
|
4778
|
+
if (lstatSync(current).isSymbolicLink())
|
|
4779
|
+
return true;
|
|
4780
|
+
} catch {
|
|
4781
|
+
return false;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
return false;
|
|
4785
|
+
}
|
|
4786
|
+
function isPlainObject2(value) {
|
|
4787
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
4788
|
+
}
|
|
4789
|
+
function serializeJson(value) {
|
|
4790
|
+
return JSON.stringify(value, null, 2) + "\n";
|
|
4791
|
+
}
|
|
4792
|
+
var Refusal = class extends Error {
|
|
4793
|
+
};
|
|
4794
|
+
function readMergedJson(targetRepo, rel) {
|
|
4795
|
+
if (symlinkOnPath(targetRepo, rel))
|
|
4796
|
+
return { state: "symlink" };
|
|
4797
|
+
const path = join10(targetRepo, rel);
|
|
4798
|
+
if (!lexists(path))
|
|
4799
|
+
return { state: "absent" };
|
|
4800
|
+
let value;
|
|
4801
|
+
try {
|
|
4802
|
+
value = JSON.parse(readFileSync8(path, "utf-8"));
|
|
4803
|
+
} catch {
|
|
4804
|
+
value = void 0;
|
|
4805
|
+
}
|
|
4806
|
+
if (!isPlainObject2(value)) {
|
|
4807
|
+
throw new Refusal(`${rel} is not a JSON object, so Halfcycle cannot take its own entries out of it and nothing was removed. Fix it or delete it, then run "npx halfcycle uninstall" again.`);
|
|
4808
|
+
}
|
|
4809
|
+
return { state: "object", value };
|
|
4810
|
+
}
|
|
4811
|
+
function validate(options) {
|
|
4812
|
+
const { targetRepo, removeCredential, home } = options;
|
|
4813
|
+
const manifestPath = join10(targetRepo, INSTALL_MANIFEST_REL);
|
|
4814
|
+
const pinPath = join10(targetRepo, BUNDLE_PIN_REL);
|
|
4815
|
+
if (symlinkOnPath(targetRepo, INSTALL_MANIFEST_REL) || symlinkOnPath(targetRepo, BUNDLE_PIN_REL)) {
|
|
4816
|
+
throw new Refusal(`.halfcycle, or a file Halfcycle keeps in it, is a symbolic link here, so nothing was followed and nothing was removed.`);
|
|
4817
|
+
}
|
|
4818
|
+
if (!lexists(manifestPath)) {
|
|
4819
|
+
if (!lexists(pinPath))
|
|
4820
|
+
return "not-installed";
|
|
4821
|
+
throw new Refusal(`Halfcycle was installed here by a version that kept no record of what it wrote, so nothing was removed. Run "npx halfcycle" once \u2014 it writes that record \u2014 then run "npx halfcycle uninstall".`);
|
|
4822
|
+
}
|
|
4823
|
+
let manifest;
|
|
4824
|
+
try {
|
|
4825
|
+
manifest = parseManifest(readFileSync8(manifestPath, "utf-8"));
|
|
4826
|
+
} catch (err) {
|
|
4827
|
+
const why = err instanceof UnreadableManifestError ? err.message : "it could not be read";
|
|
4828
|
+
throw new Refusal(`${INSTALL_MANIFEST_REL} cannot be read (${why}), so nothing was removed. Restore it from git, or run "npx halfcycle" once to rewrite it, then run "npx halfcycle uninstall" again.`);
|
|
4829
|
+
}
|
|
4830
|
+
let engagementId;
|
|
4831
|
+
let pinReadable = true;
|
|
4832
|
+
if (lexists(pinPath)) {
|
|
4833
|
+
try {
|
|
4834
|
+
const pin = JSON.parse(readFileSync8(pinPath, "utf-8"));
|
|
4835
|
+
const id = isPlainObject2(pin) ? pin["engagementId"] : void 0;
|
|
4836
|
+
engagementId = typeof id === "string" && id !== "" ? id : void 0;
|
|
4837
|
+
} catch {
|
|
4838
|
+
pinReadable = false;
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
let idValid = false;
|
|
4842
|
+
if (engagementId !== void 0) {
|
|
4843
|
+
try {
|
|
4844
|
+
assertEngagementId(engagementId);
|
|
4845
|
+
idValid = true;
|
|
4846
|
+
} catch {
|
|
4847
|
+
idValid = false;
|
|
4848
|
+
}
|
|
4849
|
+
}
|
|
4850
|
+
if (removeCredential) {
|
|
4851
|
+
if (!pinReadable) {
|
|
4852
|
+
throw new Refusal(`${BUNDLE_PIN_REL} cannot be read, so there is no project id to find this machine's credential by. Nothing was removed.`);
|
|
4853
|
+
}
|
|
4854
|
+
if (engagementId !== void 0 && !idValid) {
|
|
4855
|
+
throw new Refusal(`the project id in ${BUNDLE_PIN_REL} is not a valid Halfcycle project id, so it cannot name a folder on this machine. Nothing was removed, here or under ~/.halfcycle.`);
|
|
4856
|
+
}
|
|
4857
|
+
if (engagementId !== void 0) {
|
|
4858
|
+
const expectedParent = join10(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
|
|
4859
|
+
if (dirname4(engagementStateDir(engagementId, home)) !== expectedParent) {
|
|
4860
|
+
throw new Refusal(`the project id in ${BUNDLE_PIN_REL} does not name a folder under ~/.halfcycle/engagements. Nothing was removed.`);
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
return {
|
|
4865
|
+
manifest,
|
|
4866
|
+
engagementId,
|
|
4867
|
+
idValid,
|
|
4868
|
+
settings: readMergedJson(targetRepo, SETTINGS_REL),
|
|
4869
|
+
mcp: manifest.mcp !== void 0 ? readMergedJson(targetRepo, MCP_REGISTRATION_REL) : { state: "absent" }
|
|
4870
|
+
};
|
|
4871
|
+
}
|
|
4872
|
+
function unmergeSettings(value, rec, writeSet) {
|
|
4873
|
+
let changed = false;
|
|
4874
|
+
const refused = [];
|
|
4875
|
+
const commands = new Set(rec.hookCommands.filter((c) => writeSet.hookCommands.has(c)));
|
|
4876
|
+
for (const command of rec.hookCommands) {
|
|
4877
|
+
if (!writeSet.hookCommands.has(command)) {
|
|
4878
|
+
refused.push(`${SETTINGS_REL}: the hook command ${JSON.stringify(command)} (Halfcycle never writes it, so it was kept)`);
|
|
4879
|
+
}
|
|
4880
|
+
}
|
|
4881
|
+
const denyHashes = rec.denyAddedSha256.filter((h) => writeSet.denyRuleSha256.has(h));
|
|
4882
|
+
if (denyHashes.length < rec.denyAddedSha256.length) {
|
|
4883
|
+
refused.push(`${SETTINGS_REL}: a deny rule the install record names but Halfcycle never adds (it was kept)`);
|
|
4884
|
+
}
|
|
4885
|
+
const hooks = value["hooks"];
|
|
4886
|
+
if (isPlainObject2(hooks)) {
|
|
4887
|
+
let emptiedAnEvent = false;
|
|
4888
|
+
for (const event of Object.keys(hooks)) {
|
|
4889
|
+
const entries = hooks[event];
|
|
4890
|
+
if (!Array.isArray(entries))
|
|
4891
|
+
continue;
|
|
4892
|
+
let touched = false;
|
|
4893
|
+
const next = [];
|
|
4894
|
+
for (const entry of entries) {
|
|
4895
|
+
const wellFormed = isPlainObject2(entry) && Array.isArray(entry["hooks"]) && entry["hooks"].every(isPlainObject2);
|
|
4896
|
+
if (!wellFormed || !isHalfcycleEntry(entry, commands)) {
|
|
4897
|
+
next.push(entry);
|
|
4898
|
+
continue;
|
|
4899
|
+
}
|
|
4900
|
+
touched = true;
|
|
4901
|
+
const remaining = entry["hooks"].filter((h) => !(typeof h["command"] === "string" && commands.has(h["command"])));
|
|
4902
|
+
if (remaining.length > 0)
|
|
4903
|
+
next.push({ ...entry, hooks: remaining });
|
|
4904
|
+
}
|
|
4905
|
+
if (touched)
|
|
4906
|
+
changed = true;
|
|
4907
|
+
if (next.length === 0 && (rec.eventsCreated.includes(event) || rec.adopted && touched)) {
|
|
4908
|
+
delete hooks[event];
|
|
4909
|
+
changed = true;
|
|
4910
|
+
emptiedAnEvent = true;
|
|
4911
|
+
} else if (touched) {
|
|
4912
|
+
hooks[event] = next;
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
if (Object.keys(hooks).length === 0 && (rec.hooksCreated || rec.adopted && emptiedAnEvent)) {
|
|
4916
|
+
delete value["hooks"];
|
|
4917
|
+
changed = true;
|
|
4918
|
+
}
|
|
4919
|
+
}
|
|
4920
|
+
const permissions = value["permissions"];
|
|
4921
|
+
if (isPlainObject2(permissions) && Array.isArray(permissions["deny"])) {
|
|
4922
|
+
const deny = permissions["deny"];
|
|
4923
|
+
for (const hash of denyHashes) {
|
|
4924
|
+
for (let i = deny.length - 1; i >= 0; i--) {
|
|
4925
|
+
const rule = deny[i];
|
|
4926
|
+
if (typeof rule === "string" && sha256Hex(rule) === hash) {
|
|
4927
|
+
deny.splice(i, 1);
|
|
4928
|
+
changed = true;
|
|
4929
|
+
break;
|
|
4930
|
+
}
|
|
4931
|
+
}
|
|
4932
|
+
}
|
|
4933
|
+
if (deny.length === 0 && rec.denyCreated) {
|
|
4934
|
+
delete permissions["deny"];
|
|
4935
|
+
changed = true;
|
|
4936
|
+
}
|
|
4937
|
+
if (Object.keys(permissions).length === 0 && rec.permissionsCreated) {
|
|
4938
|
+
delete value["permissions"];
|
|
4939
|
+
changed = true;
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
if ("schemaBefore" in rec) {
|
|
4943
|
+
if (rec.schemaBefore === null) {
|
|
4944
|
+
if ("$schema" in value) {
|
|
4945
|
+
delete value["$schema"];
|
|
4946
|
+
changed = true;
|
|
4947
|
+
}
|
|
4948
|
+
} else if (value["$schema"] !== rec.schemaBefore) {
|
|
4949
|
+
value["$schema"] = rec.schemaBefore;
|
|
4950
|
+
changed = true;
|
|
4951
|
+
}
|
|
4952
|
+
}
|
|
4953
|
+
return { changed, refused };
|
|
4954
|
+
}
|
|
4955
|
+
function unmergeMcp(value, rec) {
|
|
4956
|
+
const servers = value["mcpServers"];
|
|
4957
|
+
if (!isPlainObject2(servers))
|
|
4958
|
+
return { changed: false, keptEntry: false };
|
|
4959
|
+
let changed = false;
|
|
4960
|
+
let keptEntry = false;
|
|
4961
|
+
let removedEntry = false;
|
|
4962
|
+
if (MCP_SERVER_KEY in servers) {
|
|
4963
|
+
if (rec.entrySha256 !== void 0 && canonicalSha256(servers[MCP_SERVER_KEY]) === rec.entrySha256) {
|
|
4964
|
+
delete servers[MCP_SERVER_KEY];
|
|
4965
|
+
changed = true;
|
|
4966
|
+
removedEntry = true;
|
|
4967
|
+
} else {
|
|
4968
|
+
keptEntry = true;
|
|
4969
|
+
}
|
|
4970
|
+
}
|
|
4971
|
+
if (Object.keys(servers).length === 0 && (rec.mcpServersCreated || rec.adopted && removedEntry)) {
|
|
4972
|
+
delete value["mcpServers"];
|
|
4973
|
+
changed = true;
|
|
4974
|
+
}
|
|
4975
|
+
return { changed, keptEntry };
|
|
4976
|
+
}
|
|
4977
|
+
function trimGitignore(targetRepo, text, rec, writeSet) {
|
|
4978
|
+
const keptLines = [];
|
|
4979
|
+
const foreignLines = [];
|
|
4980
|
+
let current = text;
|
|
4981
|
+
for (const block of [...rec.appended].reverse()) {
|
|
4982
|
+
const lines = block.split("\n").filter((line) => line !== "");
|
|
4983
|
+
const foreign = lines.filter((line) => !writeSet.gitignoreLines.has(line));
|
|
4984
|
+
foreignLines.push(...foreign);
|
|
4985
|
+
const ours = lines.filter((line) => writeSet.gitignoreLines.has(line));
|
|
4986
|
+
const entries = ours.filter((line) => line !== GITIGNORE_HEADER);
|
|
4987
|
+
const kept = entries.filter((line) => coveredPathExists(targetRepo, line));
|
|
4988
|
+
keptLines.push(...kept);
|
|
4989
|
+
if (kept.length === 0 && foreign.length === 0 && current.includes(block)) {
|
|
4990
|
+
const at = current.lastIndexOf(block);
|
|
4991
|
+
current = current.slice(0, at) + current.slice(at + block.length);
|
|
4992
|
+
continue;
|
|
4993
|
+
}
|
|
4994
|
+
const separated = block.startsWith("\n") || block.includes("\n\n");
|
|
4995
|
+
const removable = ours.filter((line) => line === GITIGNORE_HEADER ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
|
|
4996
|
+
for (const line of removable) {
|
|
4997
|
+
const fileLines = current.split("\n");
|
|
4998
|
+
const at = fileLines.lastIndexOf(line);
|
|
4999
|
+
if (at < 0)
|
|
5000
|
+
continue;
|
|
5001
|
+
const withSeparator = line === GITIGNORE_HEADER && separated && at > 0 && fileLines[at - 1] === "";
|
|
5002
|
+
fileLines.splice(withSeparator ? at - 1 : at, withSeparator ? 2 : 1);
|
|
5003
|
+
current = fileLines.join("\n");
|
|
5004
|
+
}
|
|
5005
|
+
}
|
|
5006
|
+
return { text: current, keptLines, foreignLines };
|
|
5007
|
+
}
|
|
5008
|
+
function coveredPathExists(targetRepo, line) {
|
|
5009
|
+
const path = line.trim().replace(/^\//, "").replace(/\/$/, "");
|
|
5010
|
+
return path !== "" && lexists(join10(targetRepo, path));
|
|
5011
|
+
}
|
|
5012
|
+
function uninstall(options) {
|
|
5013
|
+
const result = {
|
|
5014
|
+
removed: [],
|
|
5015
|
+
restored: [],
|
|
5016
|
+
kept: [],
|
|
5017
|
+
keptIgnoreLines: [],
|
|
5018
|
+
leftAlone: [],
|
|
5019
|
+
failed: []
|
|
5020
|
+
};
|
|
5021
|
+
const nothingToSay = { kind: "nothing-to-say" };
|
|
5022
|
+
let checked;
|
|
5023
|
+
try {
|
|
5024
|
+
checked = validate(options);
|
|
5025
|
+
} catch (err) {
|
|
5026
|
+
if (err instanceof Refusal) {
|
|
5027
|
+
return { outcome: "refused", exitCode: 1, refusal: err.message, ...result, credential: nothingToSay };
|
|
5028
|
+
}
|
|
5029
|
+
throw err;
|
|
5030
|
+
}
|
|
5031
|
+
if (checked === "not-installed") {
|
|
5032
|
+
return { outcome: "not-installed", exitCode: 0, ...result, credential: nothingToSay };
|
|
5033
|
+
}
|
|
5034
|
+
const { targetRepo, home } = options;
|
|
5035
|
+
const { manifest } = checked;
|
|
5036
|
+
const writeSet = closedWriteSet();
|
|
5037
|
+
const named = /* @__PURE__ */ new Set();
|
|
5038
|
+
const name = (list, text, path) => {
|
|
5039
|
+
list.push(text);
|
|
5040
|
+
if (path !== void 0)
|
|
5041
|
+
named.add(path);
|
|
5042
|
+
};
|
|
5043
|
+
applyMergedJson(targetRepo, SETTINGS_REL, checked.settings, result, name, (value) => {
|
|
5044
|
+
const { changed, refused } = unmergeSettings(value, manifest.settings, writeSet);
|
|
5045
|
+
for (const note of refused)
|
|
5046
|
+
name(result.leftAlone, note);
|
|
5047
|
+
return { changed, created: manifest.settings.created, adopted: manifest.settings.adopted, keptNote: void 0 };
|
|
5048
|
+
});
|
|
5049
|
+
const mcpRecord = manifest.mcp;
|
|
5050
|
+
if (mcpRecord !== void 0) {
|
|
5051
|
+
applyMergedJson(targetRepo, MCP_REGISTRATION_REL, checked.mcp, result, name, (value) => {
|
|
5052
|
+
const { changed, keptEntry } = unmergeMcp(value, mcpRecord);
|
|
5053
|
+
return {
|
|
5054
|
+
changed,
|
|
5055
|
+
created: mcpRecord.created,
|
|
5056
|
+
adopted: mcpRecord.adopted,
|
|
5057
|
+
keptNote: keptEntry ? `${MCP_REGISTRATION_REL} (its "${MCP_SERVER_KEY}" server entry)` : void 0
|
|
5058
|
+
};
|
|
5059
|
+
});
|
|
5060
|
+
}
|
|
5061
|
+
const deferred = /* @__PURE__ */ new Set([BUNDLE_PIN_REL, INSTALL_MANIFEST_REL]);
|
|
5062
|
+
for (const entry of manifest.files) {
|
|
5063
|
+
if (deferred.has(entry.path))
|
|
5064
|
+
continue;
|
|
5065
|
+
removeRecordedFile(targetRepo, entry, writeSet, result, name);
|
|
5066
|
+
}
|
|
5067
|
+
for (const path of manifest.leftAlone) {
|
|
5068
|
+
if (lexists(join10(targetRepo, path)))
|
|
5069
|
+
name(result.leftAlone, path, path);
|
|
5070
|
+
}
|
|
5071
|
+
switch (removeLegacyCiStanza(targetRepo)) {
|
|
5072
|
+
case "removed":
|
|
5073
|
+
name(result.removed, ".halfcycle/ci-stanza.yml", ".halfcycle/ci-stanza.yml");
|
|
5074
|
+
break;
|
|
5075
|
+
case "kept-foreign":
|
|
5076
|
+
name(result.leftAlone, ".halfcycle/ci-stanza.yml", ".halfcycle/ci-stanza.yml");
|
|
5077
|
+
break;
|
|
5078
|
+
case "kept-uncommitted":
|
|
5079
|
+
name(result.kept, ".halfcycle/ci-stanza.yml", ".halfcycle/ci-stanza.yml");
|
|
5080
|
+
break;
|
|
5081
|
+
case "failed":
|
|
5082
|
+
name(result.failed, ".halfcycle/ci-stanza.yml", ".halfcycle/ci-stanza.yml");
|
|
5083
|
+
break;
|
|
5084
|
+
case "absent":
|
|
5085
|
+
break;
|
|
5086
|
+
}
|
|
5087
|
+
const createdDirs = [.../* @__PURE__ */ new Set([...manifest.createdDirs, ...writeSet.ownedDirs])].sort((a, b) => b.split("/").length - a.split("/").length || (a < b ? 1 : a > b ? -1 : 0));
|
|
5088
|
+
for (const dir of createdDirs) {
|
|
5089
|
+
if (dir === ".halfcycle")
|
|
5090
|
+
continue;
|
|
5091
|
+
removeEmptyCreatedDir(targetRepo, dir, writeSet, result, name);
|
|
5092
|
+
}
|
|
5093
|
+
trimGitignoreFile(targetRepo, manifest.gitignore, writeSet, result, name);
|
|
5094
|
+
const credential = handleCredential(options, checked);
|
|
5095
|
+
const credentialFailed = credential.kind === "failed";
|
|
5096
|
+
if (result.failed.length === 0 && !credentialFailed) {
|
|
5097
|
+
const pinEntry = manifest.files.find((f) => f.path === BUNDLE_PIN_REL);
|
|
5098
|
+
if (pinEntry !== void 0)
|
|
5099
|
+
removeRecordedFile(targetRepo, pinEntry, writeSet, result, name);
|
|
5100
|
+
else if (lexists(join10(targetRepo, BUNDLE_PIN_REL)))
|
|
5101
|
+
name(result.leftAlone, BUNDLE_PIN_REL, BUNDLE_PIN_REL);
|
|
5102
|
+
try {
|
|
5103
|
+
rmSync2(join10(targetRepo, INSTALL_MANIFEST_REL), { force: true });
|
|
5104
|
+
named.add(INSTALL_MANIFEST_REL);
|
|
5105
|
+
} catch {
|
|
5106
|
+
name(result.failed, INSTALL_MANIFEST_REL, INSTALL_MANIFEST_REL);
|
|
5107
|
+
}
|
|
5108
|
+
if (manifest.createdDirs.includes(".halfcycle") || writeSet.ownedDirs.has(".halfcycle")) {
|
|
5109
|
+
removeEmptyCreatedDir(targetRepo, ".halfcycle", writeSet, result, name);
|
|
5110
|
+
}
|
|
5111
|
+
} else {
|
|
5112
|
+
named.add(BUNDLE_PIN_REL);
|
|
5113
|
+
named.add(INSTALL_MANIFEST_REL);
|
|
5114
|
+
}
|
|
5115
|
+
for (const path of filesUnder(targetRepo, ".halfcycle")) {
|
|
5116
|
+
if (!named.has(path))
|
|
5117
|
+
name(result.leftAlone, path, path);
|
|
5118
|
+
}
|
|
5119
|
+
const exitCode = result.failed.length > 0 || credentialFailed || credential.kind === "no-project-id" ? 1 : 0;
|
|
5120
|
+
return { outcome: "uninstalled", exitCode, ...result, credential };
|
|
5121
|
+
}
|
|
5122
|
+
function applyMergedJson(targetRepo, rel, file, lists, name, edit) {
|
|
5123
|
+
if (file.state === "absent")
|
|
5124
|
+
return;
|
|
5125
|
+
if (file.state === "symlink") {
|
|
5126
|
+
name(lists.leftAlone, `${rel} (a symbolic link, or inside one \u2014 not followed)`, rel);
|
|
5127
|
+
return;
|
|
5128
|
+
}
|
|
5129
|
+
const value = file.value;
|
|
5130
|
+
const { changed, created, adopted, keptNote } = edit(value);
|
|
5131
|
+
if (keptNote !== void 0)
|
|
5132
|
+
name(lists.kept, keptNote, rel);
|
|
5133
|
+
const path = join10(targetRepo, rel);
|
|
5134
|
+
const empty = Object.keys(value).length === 0;
|
|
5135
|
+
try {
|
|
5136
|
+
if (empty && created && !adopted) {
|
|
5137
|
+
rmSync2(path);
|
|
5138
|
+
name(lists.removed, rel, rel);
|
|
5139
|
+
} else if (changed) {
|
|
5140
|
+
writeFileSync7(path, serializeJson(value), "utf-8");
|
|
5141
|
+
name(lists.restored, rel, rel);
|
|
5142
|
+
}
|
|
5143
|
+
if (adopted && lexists(path)) {
|
|
5144
|
+
name(lists.leftAlone, `${rel} (an older Halfcycle version changed it before keeping a record, so some of what it added may still be there \u2014 it cannot be told apart from your own)`, rel);
|
|
5145
|
+
}
|
|
5146
|
+
} catch {
|
|
5147
|
+
name(lists.failed, rel, rel);
|
|
5148
|
+
}
|
|
5149
|
+
}
|
|
5150
|
+
function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
|
|
5151
|
+
const { path } = entry;
|
|
5152
|
+
if (!writeSet.files.has(path)) {
|
|
5153
|
+
name(lists.leftAlone, `${path} (Halfcycle never writes this path, so it was not removed)`, path);
|
|
5154
|
+
return;
|
|
5155
|
+
}
|
|
5156
|
+
if (symlinkOnPath(targetRepo, path)) {
|
|
5157
|
+
name(lists.leftAlone, `${path} (a symbolic link, or inside one \u2014 not followed)`, path);
|
|
5158
|
+
return;
|
|
5159
|
+
}
|
|
5160
|
+
const abs = join10(targetRepo, path);
|
|
5161
|
+
let isFile;
|
|
5162
|
+
try {
|
|
5163
|
+
isFile = lstatSync(abs).isFile();
|
|
5164
|
+
} catch {
|
|
5165
|
+
return;
|
|
5166
|
+
}
|
|
5167
|
+
if (!isFile) {
|
|
5168
|
+
name(lists.leftAlone, `${path} (not a file)`, path);
|
|
5169
|
+
return;
|
|
5170
|
+
}
|
|
5171
|
+
if ("sha256" in entry) {
|
|
5172
|
+
let bytes;
|
|
5173
|
+
try {
|
|
5174
|
+
bytes = readFileSync8(abs);
|
|
5175
|
+
} catch {
|
|
5176
|
+
name(lists.failed, path, path);
|
|
5177
|
+
return;
|
|
5178
|
+
}
|
|
5179
|
+
if (sha256Hex(bytes) !== entry.sha256) {
|
|
5180
|
+
name(lists.kept, path, path);
|
|
5181
|
+
return;
|
|
5182
|
+
}
|
|
5183
|
+
}
|
|
5184
|
+
try {
|
|
5185
|
+
rmSync2(abs);
|
|
5186
|
+
name(lists.removed, path, path);
|
|
5187
|
+
} catch {
|
|
5188
|
+
name(lists.failed, path, path);
|
|
5189
|
+
}
|
|
5190
|
+
}
|
|
5191
|
+
function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
|
|
5192
|
+
if (!writeSet.dirs.has(dir) || symlinkOnPath(targetRepo, dir))
|
|
5193
|
+
return;
|
|
5194
|
+
const abs = join10(targetRepo, dir);
|
|
5195
|
+
try {
|
|
5196
|
+
if (!lstatSync(abs).isDirectory() || readdirSync(abs).length > 0)
|
|
5197
|
+
return;
|
|
5198
|
+
} catch {
|
|
5199
|
+
return;
|
|
5200
|
+
}
|
|
5201
|
+
try {
|
|
5202
|
+
rmdirSync(abs);
|
|
5203
|
+
} catch {
|
|
5204
|
+
name(lists.failed, `${dir}/`, dir);
|
|
5205
|
+
}
|
|
5206
|
+
}
|
|
5207
|
+
function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
|
|
5208
|
+
if (rec.appended.length === 0)
|
|
5209
|
+
return;
|
|
5210
|
+
if (symlinkOnPath(targetRepo, GITIGNORE_REL)) {
|
|
5211
|
+
name(lists.leftAlone, `${GITIGNORE_REL} (a symbolic link \u2014 not followed)`, GITIGNORE_REL);
|
|
5212
|
+
return;
|
|
5213
|
+
}
|
|
5214
|
+
const path = join10(targetRepo, GITIGNORE_REL);
|
|
5215
|
+
let before;
|
|
5216
|
+
try {
|
|
5217
|
+
before = readFileSync8(path, "utf-8");
|
|
5218
|
+
} catch {
|
|
5219
|
+
return;
|
|
5220
|
+
}
|
|
5221
|
+
const { text, keptLines, foreignLines } = trimGitignore(targetRepo, before, rec, writeSet);
|
|
5222
|
+
for (const line of foreignLines) {
|
|
5223
|
+
name(lists.leftAlone, `${GITIGNORE_REL}: the line ${JSON.stringify(line)} (Halfcycle never writes it, so it was kept)`);
|
|
5224
|
+
}
|
|
5225
|
+
for (const line of keptLines) {
|
|
5226
|
+
lists.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
|
|
5227
|
+
}
|
|
5228
|
+
if (text === before)
|
|
5229
|
+
return;
|
|
5230
|
+
try {
|
|
5231
|
+
if (text === "" && rec.created) {
|
|
5232
|
+
rmSync2(path);
|
|
5233
|
+
name(lists.removed, GITIGNORE_REL, GITIGNORE_REL);
|
|
5234
|
+
} else {
|
|
5235
|
+
writeFileSync7(path, text, "utf-8");
|
|
5236
|
+
name(lists.restored, GITIGNORE_REL, GITIGNORE_REL);
|
|
5237
|
+
}
|
|
5238
|
+
} catch {
|
|
5239
|
+
name(lists.failed, GITIGNORE_REL, GITIGNORE_REL);
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5242
|
+
function handleCredential(options, checked) {
|
|
5243
|
+
const { removeCredential, home } = options;
|
|
5244
|
+
const { engagementId, idValid } = checked;
|
|
5245
|
+
if (!removeCredential) {
|
|
5246
|
+
if (engagementId === void 0)
|
|
5247
|
+
return { kind: "nothing-to-say" };
|
|
5248
|
+
if (!idValid)
|
|
5249
|
+
return { kind: "invalid-id" };
|
|
5250
|
+
const dir2 = engagementStateDir(engagementId, home);
|
|
5251
|
+
return lexists(dir2) ? { kind: "still-held", dir: dir2 } : { kind: "nothing-to-say" };
|
|
5252
|
+
}
|
|
5253
|
+
if (engagementId === void 0)
|
|
5254
|
+
return { kind: "no-project-id" };
|
|
5255
|
+
const dir = engagementStateDir(engagementId, home);
|
|
5256
|
+
if (!lexists(dir))
|
|
5257
|
+
return { kind: "not-held", dir };
|
|
5258
|
+
try {
|
|
5259
|
+
if (lstatSync(dir).isSymbolicLink())
|
|
5260
|
+
return { kind: "failed", dir, reason: "it is a symbolic link" };
|
|
5261
|
+
rmSync2(engagementEnvPath(engagementId, home), { force: true });
|
|
5262
|
+
rmSync2(notThisAccountMarkerPath(engagementId, home), { force: true });
|
|
5263
|
+
if (readdirSync(dir).length === 0)
|
|
5264
|
+
rmdirSync(dir);
|
|
5265
|
+
} catch (err) {
|
|
5266
|
+
return { kind: "failed", dir, reason: err instanceof Error ? err.message : String(err) };
|
|
5267
|
+
}
|
|
5268
|
+
return { kind: "removed", dir };
|
|
5269
|
+
}
|
|
5270
|
+
function filesUnder(root, rel) {
|
|
5271
|
+
const out = [];
|
|
5272
|
+
const walk = (dirRel) => {
|
|
5273
|
+
let names;
|
|
5274
|
+
try {
|
|
5275
|
+
if (!lstatSync(join10(root, dirRel)).isDirectory())
|
|
5276
|
+
return;
|
|
5277
|
+
names = readdirSync(join10(root, dirRel));
|
|
5278
|
+
} catch {
|
|
5279
|
+
return;
|
|
5280
|
+
}
|
|
5281
|
+
for (const entry of names.sort()) {
|
|
5282
|
+
const childRel = `${dirRel}/${entry}`;
|
|
5283
|
+
try {
|
|
5284
|
+
if (lstatSync(join10(root, childRel)).isDirectory())
|
|
5285
|
+
walk(childRel);
|
|
5286
|
+
else
|
|
5287
|
+
out.push(childRel);
|
|
5288
|
+
} catch {
|
|
5289
|
+
continue;
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
};
|
|
5293
|
+
walk(rel);
|
|
5294
|
+
return out;
|
|
5295
|
+
}
|
|
5296
|
+
function shownPath(path, home) {
|
|
5297
|
+
const base = home ?? homedir2();
|
|
5298
|
+
const rel = relative3(base, path);
|
|
5299
|
+
const under = rel !== "" && !rel.startsWith("..") && !rel.startsWith(sep);
|
|
5300
|
+
return `${under ? `~/${rel.split(sep).join("/")}` : path}/`;
|
|
5301
|
+
}
|
|
5302
|
+
function renderUninstallReport(result, home) {
|
|
5303
|
+
if (result.outcome === "refused") {
|
|
5304
|
+
return { stdout: "", stderr: `halfcycle uninstall: ${result.refusal ?? "refused."}
|
|
5305
|
+
` };
|
|
5306
|
+
}
|
|
5307
|
+
if (result.outcome === "not-installed") {
|
|
5308
|
+
return { stdout: "[halfcycle] Halfcycle is not installed in this directory, so there is nothing to remove.\n", stderr: "" };
|
|
5309
|
+
}
|
|
5310
|
+
const out = [];
|
|
5311
|
+
const section = (title, items) => {
|
|
5312
|
+
if (items.length === 0)
|
|
5313
|
+
return;
|
|
5314
|
+
out.push(`[halfcycle] ${title}`);
|
|
5315
|
+
for (const item of items)
|
|
5316
|
+
out.push(` ${item}`);
|
|
5317
|
+
};
|
|
5318
|
+
section("Removed from this project:", result.removed);
|
|
5319
|
+
section("Restored \u2014 only Halfcycle's entries were taken out:", result.restored);
|
|
5320
|
+
section("Kept, because it changed after Halfcycle wrote it \u2014 delete it yourself if you do not want it:", result.kept);
|
|
5321
|
+
section('Kept in .gitignore, because the path each line covers still exists \u2014 without the line, "git add ." would commit it:', result.keptIgnoreLines);
|
|
5322
|
+
section("Left alone \u2014 Halfcycle did not write these, or cannot tell that it did:", result.leftAlone);
|
|
5323
|
+
section('Could not remove \u2014 fix the cause, then run "npx halfcycle uninstall" again to finish:', result.failed);
|
|
5324
|
+
const c = result.credential;
|
|
5325
|
+
switch (c.kind) {
|
|
5326
|
+
case "still-held":
|
|
5327
|
+
out.push(`[halfcycle] This machine still holds this project's credential, at ${shownPath(c.dir, home)}.`);
|
|
5328
|
+
out.push(" Every checkout and worktree of this project on this machine uses it.");
|
|
5329
|
+
out.push(` To remove it too: npx halfcycle uninstall ${REMOVE_CREDENTIAL_FLAG}`);
|
|
5330
|
+
break;
|
|
5331
|
+
case "invalid-id":
|
|
5332
|
+
out.push(`[halfcycle] This machine may still hold a credential for this project, but the project id in ${BUNDLE_PIN_REL} is not valid, so it is not shown as a path.`);
|
|
5333
|
+
break;
|
|
5334
|
+
case "removed":
|
|
5335
|
+
out.push(`[halfcycle] Removed this machine's credential for this project, at ${shownPath(c.dir, home)}.`);
|
|
5336
|
+
out.push(" Every other checkout and worktree of this project on this machine used it too. Each stops reaching");
|
|
5337
|
+
out.push(" Halfcycle until `npx halfcycle` is run there again.");
|
|
5338
|
+
break;
|
|
5339
|
+
case "not-held":
|
|
5340
|
+
out.push(`[halfcycle] This machine holds no credential for this project (nothing at ${shownPath(c.dir, home)}).`);
|
|
5341
|
+
break;
|
|
5342
|
+
case "no-project-id":
|
|
5343
|
+
out.push(`[halfcycle] No credential was removed: ${BUNDLE_PIN_REL} is missing, so there is no project id to find it by.`);
|
|
5344
|
+
break;
|
|
5345
|
+
case "failed":
|
|
5346
|
+
out.push(`[halfcycle] Could not remove this machine's credential for this project, at ${shownPath(c.dir, home)}: ${c.reason}. Run "npx halfcycle uninstall ${REMOVE_CREDENTIAL_FLAG}" again to finish.`);
|
|
5347
|
+
break;
|
|
5348
|
+
case "nothing-to-say":
|
|
5349
|
+
break;
|
|
5350
|
+
}
|
|
5351
|
+
out.push("[halfcycle] Your project on Halfcycle is unchanged. Restart any Claude Code session open in this project.");
|
|
5352
|
+
return { stdout: out.join("\n") + "\n", stderr: "" };
|
|
5353
|
+
}
|
|
5354
|
+
|
|
4309
5355
|
// dist/banner-facts.js
|
|
4310
|
-
import { execFileSync as
|
|
4311
|
-
import { readFileSync as
|
|
4312
|
-
import { basename as basename3, join as
|
|
5356
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
5357
|
+
import { readFileSync as readFileSync9 } from "node:fs";
|
|
5358
|
+
import { basename as basename3, join as join11, resolve as resolve3 } from "node:path";
|
|
4313
5359
|
var BRAND_URL = "halfcycle.ai";
|
|
4314
5360
|
var TAGLINE = [
|
|
4315
5361
|
"Fewer cycles.",
|
|
@@ -4322,7 +5368,7 @@ var TAGLINE_NARROW = [
|
|
|
4322
5368
|
];
|
|
4323
5369
|
function bundleVersion() {
|
|
4324
5370
|
try {
|
|
4325
|
-
const pkg = JSON.parse(
|
|
5371
|
+
const pkg = JSON.parse(readFileSync9(join11(BUNDLE_ROOT, "package.json"), "utf-8"));
|
|
4326
5372
|
return typeof pkg.version === "string" ? pkg.version : void 0;
|
|
4327
5373
|
} catch {
|
|
4328
5374
|
return void 0;
|
|
@@ -4330,7 +5376,7 @@ function bundleVersion() {
|
|
|
4330
5376
|
}
|
|
4331
5377
|
function claudeCodeVersion() {
|
|
4332
5378
|
try {
|
|
4333
|
-
const raw =
|
|
5379
|
+
const raw = execFileSync4("claude", ["--version"], {
|
|
4334
5380
|
encoding: "utf-8",
|
|
4335
5381
|
timeout: 3e3,
|
|
4336
5382
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -4442,7 +5488,7 @@ function compareVersions(a, b) {
|
|
|
4442
5488
|
function reportClaudeCodeVersion(verbose2) {
|
|
4443
5489
|
let raw;
|
|
4444
5490
|
try {
|
|
4445
|
-
raw =
|
|
5491
|
+
raw = execFileSync5("claude", ["--version"], {
|
|
4446
5492
|
encoding: "utf-8",
|
|
4447
5493
|
timeout: 5e3,
|
|
4448
5494
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -4474,23 +5520,36 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
|
|
|
4474
5520
|
halfcycle build-record <phase> [--repo <root>]
|
|
4475
5521
|
halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
|
|
4476
5522
|
halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
|
|
4477
|
-
halfcycle
|
|
4478
|
-
|
|
5523
|
+
halfcycle uninstall [${REMOVE_CREDENTIAL_FLAG}]
|
|
5524
|
+
take out what the installer added to this project, keeping anything you changed
|
|
5525
|
+
${REMOVE_CREDENTIAL_FLAG}: also remove this machine's credential for the project, which every
|
|
5526
|
+
checkout and worktree of that project on this machine shares
|
|
4479
5527
|
|
|
4480
5528
|
--quiet / -q: print no opening banner (any command)
|
|
4481
5529
|
--verbose / -v: print full run detail (paths written, merged, skipped) on install
|
|
5530
|
+
|
|
5531
|
+
Support: hello@halfcycle.ai
|
|
4482
5532
|
`;
|
|
4483
5533
|
function isHalfcycleMonorepo(dir) {
|
|
4484
5534
|
try {
|
|
4485
|
-
const pkg = JSON.parse(
|
|
5535
|
+
const pkg = JSON.parse(readFileSync10(join12(dir, "package.json"), "utf-8"));
|
|
4486
5536
|
return pkg.name === "halfcycle-monorepo";
|
|
4487
5537
|
} catch {
|
|
4488
5538
|
return false;
|
|
4489
5539
|
}
|
|
4490
5540
|
}
|
|
5541
|
+
var RETIRED_VERBS = /* @__PURE__ */ new Map([
|
|
5542
|
+
["ci", '"ci" was removed \u2014 CI setup is part of your project, not Halfcycle']
|
|
5543
|
+
]);
|
|
4491
5544
|
async function main() {
|
|
4492
5545
|
const [cmd, ...rest] = args;
|
|
4493
|
-
|
|
5546
|
+
if (cmd !== void 0 && RETIRED_VERBS.has(cmd)) {
|
|
5547
|
+
process.stderr.write(`halfcycle: ${RETIRED_VERBS.get(cmd)}
|
|
5548
|
+
`);
|
|
5549
|
+
process.exit(2);
|
|
5550
|
+
return;
|
|
5551
|
+
}
|
|
5552
|
+
const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !CLI_VERBS.includes(cmd);
|
|
4494
5553
|
const installArm = cmd === "install" || cmd === void 0 || bareTarget;
|
|
4495
5554
|
const positionals = cmd === "install" ? rest : args;
|
|
4496
5555
|
const targetRepo = installArm ? positionals[0] ?? process.cwd() : process.cwd();
|
|
@@ -4506,6 +5565,12 @@ ${USAGE}`);
|
|
|
4506
5565
|
if (installArm) {
|
|
4507
5566
|
const engagementIdArg = positionals[1];
|
|
4508
5567
|
const engagementTypeRaw = positionals[2] ?? "client";
|
|
5568
|
+
if (!existsSync5(targetRepo) || !statSync(targetRepo).isDirectory()) {
|
|
5569
|
+
process.stderr.write(`halfcycle: no such directory: ${targetRepo}
|
|
5570
|
+
`);
|
|
5571
|
+
process.exit(1);
|
|
5572
|
+
return;
|
|
5573
|
+
}
|
|
4509
5574
|
if (isHalfcycleMonorepo(targetRepo)) {
|
|
4510
5575
|
process.stderr.write(`halfcycle: refusing to install into the Halfcycle monorepo itself (${targetRepo}).
|
|
4511
5576
|
The bare form installs into the CURRENT directory, and this directory is the
|
|
@@ -4529,6 +5594,8 @@ ${USAGE}`);
|
|
|
4529
5594
|
process.stdout.write(`[halfcycle] Halfcycle service: ${controlOriginNote(controlOrigin)}
|
|
4530
5595
|
`);
|
|
4531
5596
|
}
|
|
5597
|
+
if (engagementIdArg !== void 0)
|
|
5598
|
+
assertEngagementId(engagementIdArg);
|
|
4532
5599
|
const pinned = readPinnedEngagement(targetRepo);
|
|
4533
5600
|
const requestedId = engagementIdArg ?? pinned?.engagementId;
|
|
4534
5601
|
const held = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
|
|
@@ -4692,8 +5759,6 @@ ${USAGE}`);
|
|
|
4692
5759
|
process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
|
|
4693
5760
|
`);
|
|
4694
5761
|
process.stdout.write(`[halfcycle] Claude Code will also ask permission the first time Halfcycle needs to look up your next step. Choose allow \u2014 without it nothing can run.
|
|
4695
|
-
`);
|
|
4696
|
-
process.stdout.write(`[halfcycle] To check changes in CI too: run \`npx halfcycle ci bind <owner>/<repo>\` in this folder once, then paste the job from .halfcycle/ci-stanza.yml into a workflow \u2014 there is no secret to store.
|
|
4697
5762
|
`);
|
|
4698
5763
|
}
|
|
4699
5764
|
if (credential) {
|
|
@@ -4716,6 +5781,29 @@ ${USAGE}`);
|
|
|
4716
5781
|
return;
|
|
4717
5782
|
}
|
|
4718
5783
|
process.stderr.write(`halfcycle install failed: ${err instanceof Error ? err.message : String(err)}
|
|
5784
|
+
`);
|
|
5785
|
+
process.exit(1);
|
|
5786
|
+
}
|
|
5787
|
+
return;
|
|
5788
|
+
}
|
|
5789
|
+
if (cmd === "uninstall") {
|
|
5790
|
+
const parsed = parseUninstallArgs(rest);
|
|
5791
|
+
if ("error" in parsed) {
|
|
5792
|
+
process.stderr.write(`halfcycle uninstall: ${parsed.error}
|
|
5793
|
+
|
|
5794
|
+
Usage:
|
|
5795
|
+
${USAGE}`);
|
|
5796
|
+
process.exit(2);
|
|
5797
|
+
return;
|
|
5798
|
+
}
|
|
5799
|
+
try {
|
|
5800
|
+
const result = uninstall({ targetRepo: process.cwd(), removeCredential: parsed.removeCredential });
|
|
5801
|
+
const { stdout, stderr } = renderUninstallReport(result);
|
|
5802
|
+
process.stdout.write(stdout);
|
|
5803
|
+
process.stderr.write(stderr);
|
|
5804
|
+
process.exit(result.exitCode);
|
|
5805
|
+
} catch (err) {
|
|
5806
|
+
process.stderr.write(`halfcycle uninstall failed: ${err instanceof Error ? err.message : String(err)}
|
|
4719
5807
|
`);
|
|
4720
5808
|
process.exit(1);
|
|
4721
5809
|
}
|
|
@@ -4758,12 +5846,12 @@ ${USAGE}`);
|
|
|
4758
5846
|
const phaseId = phaseArg;
|
|
4759
5847
|
try {
|
|
4760
5848
|
assertValidPhaseIdentity(phaseId, BUILD_RECORD_DIR);
|
|
4761
|
-
const inputPath =
|
|
4762
|
-
const input = JSON.parse(
|
|
5849
|
+
const inputPath = join12(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
|
|
5850
|
+
const input = JSON.parse(readFileSync10(inputPath, "utf-8"));
|
|
4763
5851
|
const result = assemblePhaseBuildRecord({
|
|
4764
5852
|
repoRoot,
|
|
4765
|
-
phasesDir:
|
|
4766
|
-
guardEvalLogDir: input.guardEvalLogDir ??
|
|
5853
|
+
phasesDir: join12(repoRoot, "docs", "phases"),
|
|
5854
|
+
guardEvalLogDir: input.guardEvalLogDir ?? join12(repoRoot, ".workbench", "guard-eval-log"),
|
|
4767
5855
|
phaseId,
|
|
4768
5856
|
narrated: input.narrated,
|
|
4769
5857
|
touchedInvariants: input.touchedInvariants
|
|
@@ -4952,69 +6040,6 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
|
|
|
4952
6040
|
return;
|
|
4953
6041
|
}
|
|
4954
6042
|
process.stderr.write(`halfcycle close-phase failed: ${err instanceof Error ? err.message : String(err)}
|
|
4955
|
-
`);
|
|
4956
|
-
process.exit(1);
|
|
4957
|
-
}
|
|
4958
|
-
return;
|
|
4959
|
-
}
|
|
4960
|
-
if (cmd === "ci") {
|
|
4961
|
-
const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
|
|
4962
|
-
const positionals2 = rest.filter((token, i) => !token.startsWith("--") && !(i > 0 && rest[i - 1] === "--repo"));
|
|
4963
|
-
const action = positionals2[0];
|
|
4964
|
-
const repositoryArg = positionals2[1];
|
|
4965
|
-
if (action !== "bind" && action !== "unbind") {
|
|
4966
|
-
process.stderr.write("halfcycle ci: name bind or unbind.\n halfcycle ci bind <owner>/<repo> [--repo <root>]\n halfcycle ci unbind <owner>/<repo> [--repo <root>]\n");
|
|
4967
|
-
process.exit(2);
|
|
4968
|
-
return;
|
|
4969
|
-
}
|
|
4970
|
-
if (repositoryArg === void 0) {
|
|
4971
|
-
process.stderr.write(`halfcycle ci ${action}: name the repository, as owner/repo.
|
|
4972
|
-
`);
|
|
4973
|
-
process.exit(2);
|
|
4974
|
-
return;
|
|
4975
|
-
}
|
|
4976
|
-
const repository = parseCiRepositoryArg(repositoryArg);
|
|
4977
|
-
if (repository === null) {
|
|
4978
|
-
process.stderr.write(`halfcycle ci ${action}: "${repositoryArg}" is not a repository \u2014 give it as owner/repo, exactly as GitHub spells it, for example acme/widgets.
|
|
4979
|
-
`);
|
|
4980
|
-
process.exit(2);
|
|
4981
|
-
return;
|
|
4982
|
-
}
|
|
4983
|
-
const pin = readBundlePin(repoRoot);
|
|
4984
|
-
if (pin === null || pin.engagementId === "") {
|
|
4985
|
-
process.stderr.write(`halfcycle ci ${action}: ${join11(repoRoot, ".halfcycle", "bundle.json")} names no Halfcycle engagement. Run \`npx halfcycle\` here first.
|
|
4986
|
-
`);
|
|
4987
|
-
process.exit(1);
|
|
4988
|
-
return;
|
|
4989
|
-
}
|
|
4990
|
-
try {
|
|
4991
|
-
const controlOrigin = resolveControlOrigin(process.env);
|
|
4992
|
-
const label = `${repository.owner}/${repository.repo}`;
|
|
4993
|
-
if (action === "bind") {
|
|
4994
|
-
await bindCiRepository(controlOrigin.origin, pin.engagementId, repository);
|
|
4995
|
-
process.stdout.write(`[halfcycle] ${label} now trusts this engagement's CI \u2014 a workflow there granting both \`contents: read\` and \`id-token: write\` can authenticate with no stored secret. Both lines: declaring any permission replaces the defaults, so naming only the identity token stops a private repository checking out.
|
|
4996
|
-
`);
|
|
4997
|
-
} else {
|
|
4998
|
-
await unbindCiRepository(controlOrigin.origin, pin.engagementId, repository);
|
|
4999
|
-
process.stdout.write(`[halfcycle] ${label} no longer trusts this engagement's CI.
|
|
5000
|
-
`);
|
|
5001
|
-
}
|
|
5002
|
-
process.exit(0);
|
|
5003
|
-
} catch (err) {
|
|
5004
|
-
if (err instanceof SignInRefused) {
|
|
5005
|
-
process.stderr.write(`halfcycle ci ${action}: ${err.message}
|
|
5006
|
-
`);
|
|
5007
|
-
process.exit(1);
|
|
5008
|
-
return;
|
|
5009
|
-
}
|
|
5010
|
-
if (err instanceof CiBindRefused) {
|
|
5011
|
-
const label = err.unavailable ? `temporarily unavailable (${err.status})` : `refused (${err.status})`;
|
|
5012
|
-
process.stderr.write(`halfcycle ci ${action}: ${label}. ${err.message}
|
|
5013
|
-
`);
|
|
5014
|
-
process.exit(1);
|
|
5015
|
-
return;
|
|
5016
|
-
}
|
|
5017
|
-
process.stderr.write(`halfcycle ci ${action} failed: ${err instanceof Error ? err.message : String(err)}
|
|
5018
6043
|
`);
|
|
5019
6044
|
process.exit(1);
|
|
5020
6045
|
}
|
|
@@ -5048,6 +6073,7 @@ function firstPositional(argv) {
|
|
|
5048
6073
|
}
|
|
5049
6074
|
main().catch((err) => {
|
|
5050
6075
|
process.stderr.write(`halfcycle: unexpected error: ${err instanceof Error ? err.message : String(err)}
|
|
6076
|
+
Contact hello@halfcycle.ai if this keeps happening.
|
|
5051
6077
|
`);
|
|
5052
6078
|
process.exit(1);
|
|
5053
6079
|
});
|