halfcycle 0.3.11 → 0.3.12

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/dist/bin.js CHANGED
@@ -413,6 +413,7 @@ var LOOPBACK_RESULT = {
413
413
  APPROVED: "approved",
414
414
  DECLINED: "declined"
415
415
  };
416
+ var DASHBOARD_URL = "https://app.halfcycle.ai";
416
417
  function loopbackVerificationUrl(verificationUrl, target) {
417
418
  let url;
418
419
  try {
@@ -425,6 +426,16 @@ function loopbackVerificationUrl(verificationUrl, target) {
425
426
  return url.toString();
426
427
  }
427
428
 
429
+ // ../events/dist/account-identity.js
430
+ import { z as z7 } from "zod";
431
+ var accountIdentitySchema = z7.object({
432
+ accountId: z7.string().min(1),
433
+ email: z7.string().min(1).optional()
434
+ }).strict();
435
+ function safeParseAccountIdentity(payload) {
436
+ return accountIdentitySchema.safeParse(payload);
437
+ }
438
+
428
439
  // dist/engagement-credential.js
429
440
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
430
441
  import { platform as platform2 } from "node:os";
@@ -1323,12 +1334,24 @@ ${header}
1323
1334
  return "failed";
1324
1335
  }
1325
1336
  }
1326
- function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, writtenPaths) {
1337
+ function previousAccountId(targetRepoRoot, engagementId) {
1338
+ try {
1339
+ const existing = readBundlePin(targetRepoRoot);
1340
+ if (existing === null || existing.engagementId !== engagementId)
1341
+ return void 0;
1342
+ return existing.accountId;
1343
+ } catch {
1344
+ return void 0;
1345
+ }
1346
+ }
1347
+ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
1348
+ const carried = accountId ?? previousAccountId(targetRepoRoot, engagementId);
1327
1349
  const pin = {
1328
1350
  version,
1329
1351
  engagementId,
1330
1352
  engagementType,
1331
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
1353
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
1354
+ ...carried !== void 0 && carried.trim() !== "" ? { accountId: carried } : {}
1332
1355
  };
1333
1356
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1334
1357
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
@@ -1483,7 +1506,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
1483
1506
  }
1484
1507
  }
1485
1508
  async function install(options) {
1486
- const { targetRepo, engagementId, engagementType, credential, home } = options;
1509
+ const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
1487
1510
  if (!existsSync3(targetRepo)) {
1488
1511
  throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
1489
1512
  }
@@ -1572,7 +1595,7 @@ async function install(options) {
1572
1595
  case "absent":
1573
1596
  break;
1574
1597
  }
1575
- writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
1598
+ writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
1576
1599
  writeCrewRoster(targetRepo, report);
1577
1600
  const scanResult = runBootstrapScan(targetRepo);
1578
1601
  return {
@@ -1598,126 +1621,6 @@ function controlOriginNote(resolved) {
1598
1621
  return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane)`;
1599
1622
  }
1600
1623
 
1601
- // dist/create-engagement.js
1602
- var CreateEngagementRefused = class extends Error {
1603
- status;
1604
- serverMessage;
1605
- errorCode;
1606
- constructor(status, serverMessage, errorCode, message) {
1607
- super(message);
1608
- this.status = status;
1609
- this.serverMessage = serverMessage;
1610
- this.errorCode = errorCode;
1611
- this.name = "CreateEngagementRefused";
1612
- }
1613
- /**
1614
- * Is this a refusal of the CALLER's credential — the arm signing in again can fix?
1615
- *
1616
- * `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
1617
- * the whole reason this is a method rather than a status comparison at the call
1618
- * site: the flag gates the DEPLOYMENT, identically for every caller, so no
1619
- * credential and no sign-in changes the answer. Treating it as an auth refusal
1620
- * would send a developer through a browser handoff to arrive at the same 403.
1621
- *
1622
- * **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
1623
- * (T-24).** On the join route a 403 has two causes that share a status AND a
1624
- * discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
1625
- * apart: the credential may be a live engagement token carrying no account, or the
1626
- * engagement id may not resolve. Signing in again cannot fix the second, and
1627
- * treating it as though it could would discard a good credential, walk the
1628
- * developer through a browser, and arrive at the identical 403 — a remedy that
1629
- * loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
1630
- * where the reason can be written down. See `joinPinnedEngagement` there.
1631
- */
1632
- get authRefused() {
1633
- if (this.errorCode === "PublicStartDisabled")
1634
- return false;
1635
- return this.status === 401 || this.status === 403;
1636
- }
1637
- };
1638
- function planeRefusal(detail) {
1639
- let body;
1640
- try {
1641
- body = JSON.parse(detail);
1642
- } catch {
1643
- return void 0;
1644
- }
1645
- if (typeof body !== "object" || body === null)
1646
- return void 0;
1647
- const candidate = body;
1648
- if (typeof candidate.message !== "string" || candidate.message.trim() === "")
1649
- return void 0;
1650
- const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
1651
- if (!hasDiscriminant)
1652
- return void 0;
1653
- return {
1654
- message: candidate.message,
1655
- error: typeof candidate.error === "string" ? candidate.error : void 0
1656
- };
1657
- }
1658
- var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
1659
- async function createEngagement(baseUrl, name, credential) {
1660
- return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
1661
- }
1662
- async function joinEngagement(baseUrl, engagementId, credential) {
1663
- return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
1664
- action: "Join-engagement",
1665
- nothingHappened: "No credential was minted",
1666
- route: "POST /engagements/:engagementId/join"
1667
- });
1668
- }
1669
- async function requestEngagementValues(url, requestBody, credential, shape) {
1670
- const bearer = credential?.trim();
1671
- let res;
1672
- try {
1673
- res = await fetch(url, {
1674
- method: "POST",
1675
- headers: {
1676
- "content-type": "application/json",
1677
- ...bearer ? { authorization: `Bearer ${bearer}` } : {}
1678
- },
1679
- body: JSON.stringify(requestBody)
1680
- });
1681
- } catch (err) {
1682
- throw new Error(`[bundle install] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1683
- }
1684
- if (!res.ok) {
1685
- const detail = await res.text().catch(() => "");
1686
- const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
1687
- if (refusal) {
1688
- throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
1689
- }
1690
- const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
1691
- throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1692
- }
1693
- const body = await res.json().catch(() => null);
1694
- if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
1695
- throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1696
- }
1697
- for (const [field, value] of [
1698
- ["engagementId", body.engagementId],
1699
- ["sessionToken", body.sessionToken]
1700
- ]) {
1701
- if (value.trim() === "") {
1702
- throw new Error(`[bundle install] The Halfcycle plane at ${url} answered with a blank ${field} (the field is present but empty). An install cannot proceed on it: it would be written into this engagement's credential store, read back as a configured value, and every guard evaluation would fail against it for the life of the engagement. ${shape.nothingHappened} and nothing was written. ` + CONTROL_ORIGIN_HINT);
1703
- }
1704
- }
1705
- if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
1706
- throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its MCP server lives (no mcpUrl on the response). Without that address the install would write an MCP registration pointing nowhere, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
1707
- }
1708
- if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
1709
- throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
1710
- }
1711
- const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
1712
- return {
1713
- engagementId: body.engagementId,
1714
- sessionToken: body.sessionToken,
1715
- mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
1716
- guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
1717
- ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
1718
- };
1719
- }
1720
-
1721
1624
  // dist/device-signin.js
1722
1625
  import { spawn } from "node:child_process";
1723
1626
  import { platform as platform3 } from "node:os";
@@ -1728,8 +1631,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
1728
1631
  var LOOPBACK_PORT_ENV = "HALFCYCLE_LOOPBACK_PORT";
1729
1632
  var DEFAULT_STILL_WAITING_MS = 15e3;
1730
1633
  function completionPage(result) {
1731
- const headline = result === LOOPBACK_RESULT.APPROVED ? "Signed in. You can close this tab and return to your terminal." : "Sign-in declined. Nothing was created. You can close this tab.";
1732
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Halfcycle</title></head><body style="font:16px/1.5 system-ui;padding:3rem"><p>${headline}</p></body></html>`;
1634
+ const approved = result === LOOPBACK_RESULT.APPROVED;
1635
+ const headline = approved ? "Signed in. You can close this tab and return to your terminal." : "Sign-in declined. Nothing was created. You can close this tab.";
1636
+ const successLink = approved ? `<p><a href="${DASHBOARD_URL}" style="color:#c8a24b">Go to your projects</a></p>` : "";
1637
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Halfcycle</title></head><body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0a0a0a;color:#fafafa;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif"><div style="max-width:26rem;padding:2rem;text-align:center"><p>${headline}</p>${successLink}</div></body></html>`;
1733
1638
  }
1734
1639
  function isLoopbackPeer(remoteAddress) {
1735
1640
  if (remoteAddress === void 0)
@@ -1800,14 +1705,14 @@ async function bindLoopback(env = process.env) {
1800
1705
  else
1801
1706
  pending = callback;
1802
1707
  }));
1803
- const bindProblem = await new Promise((resolve2) => {
1708
+ const bindProblem = await new Promise((resolve3) => {
1804
1709
  const onError = (err) => {
1805
- resolve2(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
1710
+ resolve3(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
1806
1711
  };
1807
1712
  server.once("error", onError);
1808
1713
  server.listen({ host: LOOPBACK_REDIRECT_HOST, port: wanted.port }, () => {
1809
1714
  server.removeListener("error", onError);
1810
- resolve2(null);
1715
+ resolve3(null);
1811
1716
  });
1812
1717
  });
1813
1718
  if (bindProblem !== null) {
@@ -1823,9 +1728,9 @@ async function bindLoopback(env = process.env) {
1823
1728
  bound: true,
1824
1729
  target: { port: address.port, state },
1825
1730
  boundAddress: address.address,
1826
- waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve2) => {
1731
+ waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve3) => {
1827
1732
  if (pending !== null) {
1828
- resolve2(pending);
1733
+ resolve3(pending);
1829
1734
  return;
1830
1735
  }
1831
1736
  const startedAt = Date.now();
@@ -1835,7 +1740,7 @@ async function bindLoopback(env = process.env) {
1835
1740
  clearInterval(ticker);
1836
1741
  clearTimeout(timer);
1837
1742
  deliver = null;
1838
- resolve2(value);
1743
+ resolve3(value);
1839
1744
  };
1840
1745
  const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
1841
1746
  deliver = finish;
@@ -1844,9 +1749,9 @@ async function bindLoopback(env = process.env) {
1844
1749
  };
1845
1750
  }
1846
1751
  function closeServer(server) {
1847
- return new Promise((resolve2) => {
1752
+ return new Promise((resolve3) => {
1848
1753
  server.closeAllConnections();
1849
- server.close(() => resolve2());
1754
+ server.close(() => resolve3());
1850
1755
  });
1851
1756
  }
1852
1757
 
@@ -1868,7 +1773,7 @@ function defaultWrite(text) {
1868
1773
  process.stdout.write(text);
1869
1774
  }
1870
1775
  function defaultSleep(ms) {
1871
- return new Promise((resolve2) => setTimeout(resolve2, ms));
1776
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1872
1777
  }
1873
1778
  function browserOpenCommand(os, url) {
1874
1779
  if (os === "darwin")
@@ -1894,12 +1799,12 @@ async function openBrowser(url, env = process.env) {
1894
1799
  return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
1895
1800
  }
1896
1801
  const { command, args: args2 } = browserOpenCommand(platform3(), url);
1897
- return new Promise((resolve2) => {
1802
+ return new Promise((resolve3) => {
1898
1803
  let child;
1899
1804
  try {
1900
1805
  child = spawn(command, args2, { stdio: "ignore", env });
1901
1806
  } catch (err) {
1902
- resolve2({
1807
+ resolve3({
1903
1808
  opened: false,
1904
1809
  reason: `${command} could not be run (${err instanceof Error ? err.message : String(err)})`
1905
1810
  });
@@ -1911,7 +1816,7 @@ async function openBrowser(url, env = process.env) {
1911
1816
  return;
1912
1817
  settled = true;
1913
1818
  clearTimeout(timer);
1914
- resolve2(attempt);
1819
+ resolve3(attempt);
1915
1820
  };
1916
1821
  const timer = setTimeout(() => {
1917
1822
  child.unref();
@@ -2131,6 +2036,134 @@ async function deviceCodeSignIn(serviceUrl, started, env, deps) {
2131
2036
  };
2132
2037
  }
2133
2038
 
2039
+ // dist/create-engagement.js
2040
+ var CreateEngagementRefused = class extends Error {
2041
+ status;
2042
+ serverMessage;
2043
+ errorCode;
2044
+ constructor(status, serverMessage, errorCode, message) {
2045
+ super(message);
2046
+ this.status = status;
2047
+ this.serverMessage = serverMessage;
2048
+ this.errorCode = errorCode;
2049
+ this.name = "CreateEngagementRefused";
2050
+ }
2051
+ /**
2052
+ * Is this a refusal of the CALLER's credential — the arm signing in again can fix?
2053
+ *
2054
+ * `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
2055
+ * the whole reason this is a method rather than a status comparison at the call
2056
+ * site: the flag gates the DEPLOYMENT, identically for every caller, so no
2057
+ * credential and no sign-in changes the answer. Treating it as an auth refusal
2058
+ * would send a developer through a browser handoff to arrive at the same 403.
2059
+ *
2060
+ * **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
2061
+ * (T-24).** On the join route a 403 has two causes that share a status AND a
2062
+ * discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
2063
+ * apart: the credential may be a live engagement token carrying no account, or the
2064
+ * engagement id may not resolve. Signing in again cannot fix the second, and
2065
+ * treating it as though it could would discard a good credential, walk the
2066
+ * developer through a browser, and arrive at the identical 403 — a remedy that
2067
+ * loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
2068
+ * where the reason can be written down. See `joinPinnedEngagement` there.
2069
+ */
2070
+ get authRefused() {
2071
+ if (this.errorCode === "PublicStartDisabled")
2072
+ return false;
2073
+ return this.status === 401 || this.status === 403;
2074
+ }
2075
+ };
2076
+ function planeRefusal(detail) {
2077
+ let body;
2078
+ try {
2079
+ body = JSON.parse(detail);
2080
+ } catch {
2081
+ return void 0;
2082
+ }
2083
+ if (typeof body !== "object" || body === null)
2084
+ return void 0;
2085
+ const candidate = body;
2086
+ if (typeof candidate.message !== "string" || candidate.message.trim() === "")
2087
+ return void 0;
2088
+ const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
2089
+ if (!hasDiscriminant)
2090
+ return void 0;
2091
+ return {
2092
+ message: candidate.message,
2093
+ error: typeof candidate.error === "string" ? candidate.error : void 0
2094
+ };
2095
+ }
2096
+ var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
2097
+ async function createEngagement(baseUrl, name, credential, derivedName) {
2098
+ return requestEngagementValues(
2099
+ `${baseUrl.replace(/\/+$/, "")}/engagements`,
2100
+ // A stated name wins over a guess, and the body carries at most one of them —
2101
+ // the plane applies the same ordering, so the two cannot disagree about which
2102
+ // one a row was named from.
2103
+ name ? { name } : derivedName ? { derivedName } : {},
2104
+ credential,
2105
+ { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" }
2106
+ );
2107
+ }
2108
+ async function joinEngagement(baseUrl, engagementId, credential) {
2109
+ return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
2110
+ action: "Join-engagement",
2111
+ nothingHappened: "No credential was minted",
2112
+ route: "POST /engagements/:engagementId/join"
2113
+ });
2114
+ }
2115
+ async function requestEngagementValues(url, requestBody, credential, shape) {
2116
+ const bearer = credential?.trim();
2117
+ let res;
2118
+ try {
2119
+ res = await fetch(url, {
2120
+ method: "POST",
2121
+ headers: {
2122
+ "content-type": "application/json",
2123
+ ...bearer ? { authorization: `Bearer ${bearer}` } : {}
2124
+ },
2125
+ body: JSON.stringify(requestBody)
2126
+ });
2127
+ } catch (err) {
2128
+ throw new Error(`[bundle install] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2129
+ }
2130
+ if (!res.ok) {
2131
+ const detail = await res.text().catch(() => "");
2132
+ const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
2133
+ if (refusal) {
2134
+ throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
2135
+ }
2136
+ const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
2137
+ throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2138
+ }
2139
+ const body = await res.json().catch(() => null);
2140
+ if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
2141
+ throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2142
+ }
2143
+ for (const [field, value] of [
2144
+ ["engagementId", body.engagementId],
2145
+ ["sessionToken", body.sessionToken]
2146
+ ]) {
2147
+ if (value.trim() === "") {
2148
+ throw new Error(`[bundle install] The Halfcycle plane at ${url} answered with a blank ${field} (the field is present but empty). An install cannot proceed on it: it would be written into this engagement's credential store, read back as a configured value, and every guard evaluation would fail against it for the life of the engagement. ${shape.nothingHappened} and nothing was written. ` + CONTROL_ORIGIN_HINT);
2149
+ }
2150
+ }
2151
+ if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
2152
+ throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its MCP server lives (no mcpUrl on the response). Without that address the install would write an MCP registration pointing nowhere, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
2153
+ }
2154
+ if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
2155
+ throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
2156
+ }
2157
+ const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
2158
+ return {
2159
+ engagementId: body.engagementId,
2160
+ sessionToken: body.sessionToken,
2161
+ mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
2162
+ guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
2163
+ ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
2164
+ };
2165
+ }
2166
+
2134
2167
  // dist/resolve-credential.js
2135
2168
  async function obtainCredential(serviceUrl, opts = {}) {
2136
2169
  const env = opts.env ?? process.env;
@@ -2139,8 +2172,16 @@ async function obtainCredential(serviceUrl, opts = {}) {
2139
2172
  return { credential: fromEnv, source: "environment" };
2140
2173
  const stored = readStoredCredential(serviceUrl, opts.home);
2141
2174
  if (stored) {
2142
- return { credential: stored.credential, source: "stored", accountId: stored.accountId };
2175
+ return {
2176
+ credential: stored.credential,
2177
+ source: "stored",
2178
+ accountId: stored.accountId,
2179
+ signedInAt: stored.signedInAt
2180
+ };
2143
2181
  }
2182
+ return signInAndStore(serviceUrl, opts);
2183
+ }
2184
+ async function signInAndStore(serviceUrl, opts) {
2144
2185
  const signedIn = await signIn(serviceUrl, opts);
2145
2186
  writeStoredCredential(serviceUrl, {
2146
2187
  accountId: signedIn.accountId,
@@ -2153,21 +2194,15 @@ async function obtainCredential(serviceUrl, opts = {}) {
2153
2194
  accountId: signedIn.accountId
2154
2195
  };
2155
2196
  }
2197
+ async function switchAccount(serviceUrl, opts = {}) {
2198
+ forgetStoredCredential(serviceUrl, opts.home);
2199
+ return signInAndStore(serviceUrl, opts);
2200
+ }
2156
2201
  async function replaceRefusedCredential(serviceUrl, refused, opts = {}) {
2157
2202
  if (refused.source !== "stored")
2158
2203
  return null;
2159
2204
  forgetStoredCredential(serviceUrl, opts.home);
2160
- const signedIn = await signIn(serviceUrl, opts);
2161
- writeStoredCredential(serviceUrl, {
2162
- accountId: signedIn.accountId,
2163
- credential: signedIn.credential,
2164
- expiresAt: signedIn.expiresAt
2165
- }, opts.home);
2166
- return {
2167
- credential: signedIn.credential,
2168
- source: "browser-handoff",
2169
- accountId: signedIn.accountId
2170
- };
2205
+ return signInAndStore(serviceUrl, opts);
2171
2206
  }
2172
2207
  function refusedCredentialRemedy(source) {
2173
2208
  switch (source) {
@@ -2180,6 +2215,238 @@ function refusedCredentialRemedy(source) {
2180
2215
  }
2181
2216
  }
2182
2217
 
2218
+ // dist/confirm-identity.js
2219
+ import { createInterface } from "node:readline/promises";
2220
+ var IDENTITY_TIMEOUT_MS = 5e3;
2221
+ var IDENTITY_DECLINED = "identity-declined";
2222
+ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
2223
+ const url = `${serviceUrl.replace(/\/+$/, "")}/account`;
2224
+ const controller = new AbortController();
2225
+ const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
2226
+ try {
2227
+ const res = await fetchImpl(url, {
2228
+ headers: { authorization: `Bearer ${credential}` },
2229
+ signal: controller.signal
2230
+ });
2231
+ if (!res.ok)
2232
+ return void 0;
2233
+ const parsed = safeParseAccountIdentity(await res.json());
2234
+ return parsed.success ? parsed.data : void 0;
2235
+ } catch {
2236
+ return void 0;
2237
+ } finally {
2238
+ clearTimeout(timer);
2239
+ }
2240
+ }
2241
+ function accountLabel(identity, storedAccountId) {
2242
+ const accountId = identity?.accountId ?? storedAccountId;
2243
+ if (identity?.email !== void 0 && identity.email.trim() !== "") {
2244
+ return accountId !== void 0 ? `${identity.email} (account ${accountId})` : identity.email;
2245
+ }
2246
+ if (accountId !== void 0)
2247
+ return `account ${accountId} (no email on record for it)`;
2248
+ return "the account this credential belongs to, which this plane did not name";
2249
+ }
2250
+ function onlyDate(iso) {
2251
+ if (iso === void 0)
2252
+ return void 0;
2253
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(iso);
2254
+ return match?.[1] ?? iso;
2255
+ }
2256
+ function actClause(act) {
2257
+ return act.kind === "create" ? `create a NEW project for that account, and install into ${act.targetRepo}` : `join the project this repository is pinned to (${act.engagementId}) as that account, and install into ${act.targetRepo}`;
2258
+ }
2259
+ async function defaultAsk(question) {
2260
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2261
+ try {
2262
+ return await rl.question(question);
2263
+ } finally {
2264
+ rl.close();
2265
+ process.stdin.pause();
2266
+ }
2267
+ }
2268
+ function readDecision(answer) {
2269
+ const said = answer.trim().toLowerCase();
2270
+ if (said === "y" || said === "yes")
2271
+ return "proceed";
2272
+ if (said === "s" || said === "switch")
2273
+ return "switch-account";
2274
+ return "stop";
2275
+ }
2276
+ function actingAs(credential, identity) {
2277
+ const accountId = identity?.accountId ?? credential.accountId;
2278
+ return accountId === void 0 ? credential : { ...credential, accountId };
2279
+ }
2280
+ function armScript(credential, act, label, home) {
2281
+ switch (credential.source) {
2282
+ case "browser-handoff":
2283
+ return {
2284
+ preamble: `[halfcycle] Signed in as ${label}. This install will ${actClause(act)}.
2285
+ `,
2286
+ asks: false,
2287
+ noTerminalNote: "",
2288
+ switchOpening: "",
2289
+ stopRemedy: "",
2290
+ afterSwitch: ""
2291
+ };
2292
+ case "environment":
2293
+ return {
2294
+ preamble: `[halfcycle] HALFCYCLE_TOKEN is set in this environment, and this install will use it.
2295
+ [halfcycle] Account: ${label}
2296
+ [halfcycle] About to ${actClause(act)}
2297
+ [halfcycle] A variable exported by a shell profile is not a choice about this run \u2014 if that is not your account, the project will belong to it and not to you.
2298
+ `,
2299
+ asks: true,
2300
+ // Deliberately nothing. This is the arm's DESIGNED case — a run with no browser
2301
+ // and no human — the preamble has already named the account and the variable,
2302
+ // and a line about having nobody to ask would print on every CI job forever.
2303
+ noTerminalNote: "",
2304
+ switchOpening: `[halfcycle] Signing you in through your browser \u2014 this install will use that account instead.
2305
+ `,
2306
+ stopRemedy: `Stopped before anything was created \u2014 nothing was created and nothing was written. HALFCYCLE_TOKEN in this environment is the credential for ${label}, and it takes precedence over any saved sign-in \u2014 so unset it, or set it to your own account's credential, before running this again.`,
2307
+ // The switch is real for THIS run (the new credential is what gets used) and it
2308
+ // does not persist: `obtainCredential` reads the variable first, so the next run
2309
+ // is the same variable again. Said here rather than discovered tomorrow.
2310
+ afterSwitch: `[halfcycle] HALFCYCLE_TOKEN is still set in this environment and still wins over a saved sign-in \u2014 unset it, or the next run will use it again.
2311
+ `
2312
+ };
2313
+ case "stored": {
2314
+ const savedOn = onlyDate(credential.signedInAt);
2315
+ return {
2316
+ preamble: `[halfcycle] This machine already has a Halfcycle sign-in saved, and this install will use it.
2317
+ [halfcycle] Account: ${label}
2318
+ ` + (savedOn !== void 0 ? `[halfcycle] Saved on this machine: ${savedOn}
2319
+ ` : "") + `[halfcycle] About to ${actClause(act)}
2320
+ [halfcycle] If that is not your account, the project will belong to it and not to you \u2014 you would not see the project in your own Halfcycle.
2321
+ `,
2322
+ asks: true,
2323
+ // Not a question nobody will read — a record of what happened, the two ways to
2324
+ // make the next run act as somebody else (both executable from exactly the state
2325
+ // this is printed in), and where to look for the answer once this output is gone.
2326
+ noTerminalNote: `[halfcycle] Nothing here to ask at (no terminal), so this is continuing as that account.
2327
+ [halfcycle] To run as a different one, set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)} and run this again with a terminal to sign in.
2328
+ [halfcycle] When this install finishes, which account it used is recorded in .halfcycle/bundle.json, so it can be checked after this output is gone.
2329
+ `,
2330
+ switchOpening: `[halfcycle] Forgetting this machine's saved sign-in and signing you in through your browser.
2331
+ `,
2332
+ stopRemedy: `Stopped before anything was created \u2014 nothing was created and nothing was written. This machine's saved sign-in is for ${label}. Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
2333
+ afterSwitch: ""
2334
+ };
2335
+ }
2336
+ }
2337
+ }
2338
+ async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
2339
+ const write = deps.write ?? ((text) => process.stdout.write(text));
2340
+ const identity = await describeAccount(serviceUrl, credential.credential, deps.fetchImpl);
2341
+ const label = accountLabel(identity, credential.accountId);
2342
+ const acting = actingAs(credential, identity);
2343
+ const script = armScript(credential, act, label, deps.home);
2344
+ write(script.preamble);
2345
+ if (!script.asks)
2346
+ return acting;
2347
+ if (deps.assumeYes === true) {
2348
+ write(`[halfcycle] Continuing as that account without asking, because --yes was given.
2349
+ `);
2350
+ return acting;
2351
+ }
2352
+ const interactive = deps.interactive ?? (process.stdin.isTTY === true && !isNonInteractive(deps.env));
2353
+ if (!interactive) {
2354
+ if (script.noTerminalNote !== "")
2355
+ write(script.noTerminalNote);
2356
+ return acting;
2357
+ }
2358
+ const ask = deps.ask ?? defaultAsk;
2359
+ const decision = readDecision(await ask(`[halfcycle] Continue as that account? [y] yes [s] sign in as someone else [anything else] stop: `));
2360
+ if (decision === "proceed")
2361
+ return acting;
2362
+ if (decision === "switch-account") {
2363
+ write(script.switchOpening);
2364
+ const replacement = await switchAccount(serviceUrl, deps);
2365
+ const replacementIdentity = await describeAccount(serviceUrl, replacement.credential, deps.fetchImpl);
2366
+ write(`[halfcycle] Signed in as ${accountLabel(replacementIdentity, replacement.accountId)}. This install will ${actClause(act)}.
2367
+ `);
2368
+ if (script.afterSwitch !== "")
2369
+ write(script.afterSwitch);
2370
+ return actingAs(replacement, replacementIdentity);
2371
+ }
2372
+ throw new SignInRefused(IDENTITY_DECLINED, script.stopRemedy);
2373
+ }
2374
+
2375
+ // dist/project-name.js
2376
+ import { basename, resolve } from "node:path";
2377
+ var MAX_DERIVED_NAME_LEN = 100;
2378
+ var EMPTY_SEGMENTS = /* @__PURE__ */ new Set(["", ".", "..", "/", "\\"]);
2379
+ function repositoryNameFromRemote(remote) {
2380
+ const trimmed = remote.trim();
2381
+ if (trimmed.length === 0)
2382
+ return void 0;
2383
+ const withoutTrailingSlash = trimmed.replace(/\/+$/, "");
2384
+ const lastSeparator = Math.max(withoutTrailingSlash.lastIndexOf("/"), withoutTrailingSlash.lastIndexOf(":"));
2385
+ const segment = withoutTrailingSlash.slice(lastSeparator + 1);
2386
+ const name = segment.replace(/\.git$/i, "").trim();
2387
+ return name.length > 0 ? name : void 0;
2388
+ }
2389
+ function deriveProjectName(targetRepoRoot) {
2390
+ const remote = readRemote(targetRepoRoot);
2391
+ const fromRemote = remote ? repositoryNameFromRemote(remote) : void 0;
2392
+ const candidate = fromRemote ?? basename(resolve(targetRepoRoot));
2393
+ const name = candidate.trim();
2394
+ if (EMPTY_SEGMENTS.has(name))
2395
+ return void 0;
2396
+ return name.slice(0, MAX_DERIVED_NAME_LEN);
2397
+ }
2398
+
2399
+ // dist/own-engagement.js
2400
+ async function createOwnedEngagement(serviceUrl, targetRepo, deps = {}) {
2401
+ let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "create", targetRepo }, deps);
2402
+ for (; ; ) {
2403
+ try {
2404
+ return {
2405
+ ...await createEngagement(serviceUrl, void 0, credential.credential, deriveProjectName(targetRepo)),
2406
+ actingAccountId: credential.accountId
2407
+ };
2408
+ } catch (err) {
2409
+ if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
2410
+ throw err;
2411
+ const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
2412
+ if (replacement === null) {
2413
+ throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
2414
+ }
2415
+ process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2416
+ `);
2417
+ credential = replacement;
2418
+ }
2419
+ }
2420
+ }
2421
+ var JOIN_REFUSED_LOCAL_REMEDY = "No credential was minted and nothing was written. This repository is pinned to that engagement by .halfcycle/bundle.json \u2014 check that the id in it is the one you meant. To start a NEW engagement here instead, remove that file and re-run.";
2422
+ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps = {}) {
2423
+ let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "join", targetRepo, engagementId }, deps);
2424
+ for (; ; ) {
2425
+ try {
2426
+ return {
2427
+ ...await joinEngagement(serviceUrl, engagementId, credential.credential),
2428
+ actingAccountId: credential.accountId
2429
+ };
2430
+ } catch (err) {
2431
+ if (!(err instanceof CreateEngagementRefused))
2432
+ throw err;
2433
+ if (err.status === 403) {
2434
+ throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
2435
+ }
2436
+ if (err.status !== 401) {
2437
+ throw err;
2438
+ }
2439
+ const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
2440
+ if (replacement === null) {
2441
+ throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
2442
+ }
2443
+ process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2444
+ `);
2445
+ credential = replacement;
2446
+ }
2447
+ }
2448
+ }
2449
+
2183
2450
  // dist/mint-board-code.js
2184
2451
  async function mintBoardEnterCode(baseUrl, sessionToken) {
2185
2452
  const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
@@ -2512,14 +2779,14 @@ function renderBuildRecordMarkdown(record2) {
2512
2779
 
2513
2780
  // dist/build-record/write.js
2514
2781
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
2515
- import { join as join7, resolve, relative as relative2, isAbsolute } from "node:path";
2782
+ import { join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
2516
2783
  var BUILD_RECORD_DIR = join7("docs", "build-records");
2517
2784
  function serialiseRecordJson(record2) {
2518
2785
  return JSON.stringify(record2, null, 2) + "\n";
2519
2786
  }
2520
2787
  function writeBuildRecord(repoRoot, record2) {
2521
- const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
2522
- const expected = resolve(repoRoot, BUILD_RECORD_DIR);
2788
+ const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
2789
+ const expected = resolve2(repoRoot, BUILD_RECORD_DIR);
2523
2790
  const rel = relative2(expected, outDir);
2524
2791
  if (rel !== "" || isAbsolute(rel)) {
2525
2792
  throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
@@ -2793,7 +3060,10 @@ var PLACEHOLDERS = {
2793
3060
  };
2794
3061
 
2795
3062
  // dist/bin.js
2796
- var args = process.argv.slice(2);
3063
+ var ASSUME_YES_FLAGS = /* @__PURE__ */ new Set(["--yes", "-y"]);
3064
+ var rawArgs = process.argv.slice(2);
3065
+ var assumeYes = rawArgs.some((a) => ASSUME_YES_FLAGS.has(a));
3066
+ var args = rawArgs.filter((a) => !ASSUME_YES_FLAGS.has(a));
2797
3067
  var MIN_HEADERS_HELPER_VERSION = "2.1.118";
2798
3068
  var MIN_HEADER_ROTATION_VERSION = "2.1.193";
2799
3069
  function compareVersions(a, b) {
@@ -2832,6 +3102,7 @@ function reportClaudeCodeVersion() {
2832
3102
  }
2833
3103
  var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|client]
2834
3104
  install into target (default: the current directory)
3105
+ --yes / -y: use this machine's saved sign-in without being asked
2835
3106
  halfcycle check-drift <target-repo>
2836
3107
  halfcycle build-record <phase-id> [--repo <root>]
2837
3108
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
@@ -2845,49 +3116,6 @@ function isHalfcycleMonorepo(dir) {
2845
3116
  return false;
2846
3117
  }
2847
3118
  }
2848
- async function createOwnedEngagement(serviceUrl) {
2849
- let credential = await obtainCredential(serviceUrl);
2850
- for (; ; ) {
2851
- try {
2852
- return await createEngagement(serviceUrl, void 0, credential.credential);
2853
- } catch (err) {
2854
- if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
2855
- throw err;
2856
- const replacement = await replaceRefusedCredential(serviceUrl, credential);
2857
- if (replacement === null) {
2858
- throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
2859
- }
2860
- process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2861
- `);
2862
- credential = replacement;
2863
- }
2864
- }
2865
- }
2866
- var JOIN_REFUSED_LOCAL_REMEDY = "No credential was minted and nothing was written. This repository is pinned to that engagement by .halfcycle/bundle.json \u2014 check that the id in it is the one you meant. To start a NEW engagement here instead, remove that file and re-run.";
2867
- async function joinPinnedEngagement(serviceUrl, engagementId) {
2868
- let credential = await obtainCredential(serviceUrl);
2869
- for (; ; ) {
2870
- try {
2871
- return await joinEngagement(serviceUrl, engagementId, credential.credential);
2872
- } catch (err) {
2873
- if (!(err instanceof CreateEngagementRefused))
2874
- throw err;
2875
- if (err.status === 403) {
2876
- throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
2877
- }
2878
- if (err.status !== 401) {
2879
- throw err;
2880
- }
2881
- const replacement = await replaceRefusedCredential(serviceUrl, credential);
2882
- if (replacement === null) {
2883
- throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
2884
- }
2885
- process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2886
- `);
2887
- credential = replacement;
2888
- }
2889
- }
2890
- }
2891
3119
  async function main() {
2892
3120
  const [cmd, ...rest] = args;
2893
3121
  if (cmd === "--help" || cmd === "-h" || cmd === "help") {
@@ -2930,6 +3158,7 @@ ${USAGE}`);
2930
3158
  const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
2931
3159
  let engagementId;
2932
3160
  let credential;
3161
+ let actingAccountId;
2933
3162
  if (reusable !== void 0 && pinned !== null) {
2934
3163
  engagementId = pinned.engagementId;
2935
3164
  credential = reusable;
@@ -2943,8 +3172,9 @@ ${USAGE}`);
2943
3172
  `);
2944
3173
  }
2945
3174
  } else if (requestedId !== void 0) {
2946
- const joined = await joinPinnedEngagement(serviceUrl, requestedId);
3175
+ const joined = await joinPinnedEngagement(serviceUrl, requestedId, targetRepo, { assumeYes });
2947
3176
  engagementId = joined.engagementId;
3177
+ actingAccountId = joined.actingAccountId;
2948
3178
  credential = {
2949
3179
  serviceUrl,
2950
3180
  token: joined.sessionToken,
@@ -2958,8 +3188,9 @@ ${USAGE}`);
2958
3188
  process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
2959
3189
  `);
2960
3190
  } else {
2961
- const created = await createOwnedEngagement(serviceUrl);
3191
+ const created = await createOwnedEngagement(serviceUrl, targetRepo, { assumeYes });
2962
3192
  engagementId = created.engagementId;
3193
+ actingAccountId = created.actingAccountId;
2963
3194
  credential = {
2964
3195
  serviceUrl,
2965
3196
  token: created.sessionToken,
@@ -2980,7 +3211,8 @@ ${USAGE}`);
2980
3211
  targetRepo,
2981
3212
  engagementId,
2982
3213
  engagementType: engagementTypeRaw,
2983
- credential
3214
+ credential,
3215
+ accountId: actingAccountId
2984
3216
  });
2985
3217
  process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
2986
3218
  `);