halfcycle 0.3.10 → 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";
@@ -849,7 +860,6 @@ var WRITE_ALLOWLIST = [
849
860
  ".halfcycle/bin",
850
861
  "docs/build-records",
851
862
  "test/fixtures/captured",
852
- ".env.example",
853
863
  ".mcp.json",
854
864
  ZONE_B_DIR
855
865
  ];
@@ -1126,6 +1136,19 @@ function generateCiStanza() {
1126
1136
  # .halfcycle/bin/, so it works in a Python or Go repository as well as a Node
1127
1137
  # one \u2014 the only prerequisite is Node 20 to run the bundled binary.
1128
1138
  #
1139
+ # THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
1140
+ # product talks to has one address, the same for every user, and it ships in the
1141
+ # tools you already have: the installer, the guard hook and the binary this job
1142
+ # runs each know where to go. If something tells you to set a Halfcycle URL, it is
1143
+ # out of date.
1144
+ #
1145
+ # AND THERE IS NOTHING TO SET ON YOUR OWN MACHINE EITHER. \`npx halfcycle\` wrote
1146
+ # this engagement's credential to $HOME/.halfcycle, owner-only, outside every
1147
+ # checkout, and the guard hook reads it from there. A CI job is the one place that
1148
+ # store cannot be reached \u2014 no browser, no per-user home \u2014 which is why the two
1149
+ # names below exist at all, and why they are the only two: a credential, and the id
1150
+ # of your own engagement.
1151
+ #
1129
1152
  # WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
1130
1153
  # commits pushed straight to the default branch, which is the shape most
1131
1154
  # Halfcycle engagements settle on. \`fetch-depth\` is what makes that true: the
@@ -1162,43 +1185,6 @@ function generateCiStanza() {
1162
1185
  # (on a GitHub push event, \${{ github.event.before }} is the right value).
1163
1186
  `;
1164
1187
  }
1165
- var ENV_EXAMPLE_NAMES = [
1166
- // The engagement's own secret, under the name the guard runner reads. A CI job
1167
- // has no per-user credential store, so this is one of the two it must supply.
1168
- "GUARD_SERVICE_TOKEN",
1169
- // Which engagement this repository is. Per-engagement by construction; the same
1170
- // CI job supplies it beside the token.
1171
- "GUARD_ENGAGEMENT_ID",
1172
- // The same credential under the name every other Halfcycle surface reads. Set in
1173
- // the environment it takes precedence over the stored credential, which is the
1174
- // escape hatch for running as a specific identity.
1175
- "HALFCYCLE_TOKEN"
1176
- ];
1177
- function generateEnvExampleSection() {
1178
- return `
1179
- # ---------------------------------------------------------------------------
1180
- # Halfcycle (generated by the Halfcycle installer \u2014 names only, no values)
1181
- # ---------------------------------------------------------------------------
1182
- # YOU DO NOT NEED TO SET ANY OF THESE ON YOUR OWN MACHINE, and there is nowhere in
1183
- # this repository to put them. \`npx halfcycle\` writes this engagement's credential
1184
- # to $HOME/.halfcycle, owner-only, outside every checkout; the guard hook and the
1185
- # MCP headers helper read it from there. Nothing Halfcycle writes a secret into
1186
- # this tree, so there is no file here to leak, ignore or clean up.
1187
- #
1188
- # These three are declared because they ARE read from the environment in one place:
1189
- # a CI job, which has no browser and no per-user store and supplies them as workflow
1190
- # secrets (see .halfcycle/ci-stanza.yml). All three are yours \u2014 a credential and the
1191
- # id of your engagement.
1192
- #
1193
- # THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
1194
- # product talks to has one address, the same for every user, and it ships in the
1195
- # tools you already have: the installer, the guard hook and the CI binary each know
1196
- # where to go. If something tells you to set a Halfcycle URL, it is out of date.
1197
- GUARD_SERVICE_TOKEN=<argType:runtime>
1198
- GUARD_ENGAGEMENT_ID=<argType:runtime>
1199
- HALFCYCLE_TOKEN=<argType:runtime>
1200
- `;
1201
- }
1202
1188
  var MCP_REGISTRATION_REL = ".mcp.json";
1203
1189
  var MCP_SERVER_KEY = "halfcycle";
1204
1190
  var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
@@ -1320,21 +1306,6 @@ function generateMcpRegistration(existing, mcpOrigin) {
1320
1306
  };
1321
1307
  return JSON.stringify(base, null, 2) + "\n";
1322
1308
  }
1323
- function reconcileEnvExample(targetRepo) {
1324
- const envExamplePath = join5(targetRepo, ".env.example");
1325
- const section = generateEnvExampleSection();
1326
- if (!existsSync3(envExamplePath)) {
1327
- mkdirSync4(dirname2(envExamplePath), { recursive: true });
1328
- writeFileSync4(envExamplePath, section.trimStart(), "utf-8");
1329
- return "written";
1330
- }
1331
- const existing = readFileSync5(envExamplePath, "utf-8");
1332
- const missing = ENV_EXAMPLE_NAMES.filter((name) => !existing.includes(name));
1333
- if (missing.length === 0)
1334
- return "skipped";
1335
- writeFileSync4(envExamplePath, existing + section, "utf-8");
1336
- return "written";
1337
- }
1338
1309
  var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
1339
1310
  function gitignoreCovers(content) {
1340
1311
  const lines = new Set(content.split("\n").map((l) => l.trim()));
@@ -1363,12 +1334,24 @@ ${header}
1363
1334
  return "failed";
1364
1335
  }
1365
1336
  }
1366
- 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);
1367
1349
  const pin = {
1368
1350
  version,
1369
1351
  engagementId,
1370
1352
  engagementType,
1371
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
1353
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
1354
+ ...carried !== void 0 && carried.trim() !== "" ? { accountId: carried } : {}
1372
1355
  };
1373
1356
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1374
1357
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
@@ -1523,7 +1506,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
1523
1506
  }
1524
1507
  }
1525
1508
  async function install(options) {
1526
- const { targetRepo, engagementId, engagementType, credential, home } = options;
1509
+ const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
1527
1510
  if (!existsSync3(targetRepo)) {
1528
1511
  throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
1529
1512
  }
@@ -1565,7 +1548,6 @@ async function install(options) {
1565
1548
  }
1566
1549
  const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
1567
1550
  record(report, writeCollisionSafe(ciStanzaPath, targetRepo, generateCiStanza()), ".halfcycle/ci-stanza.yml");
1568
- record(report, reconcileEnvExample(targetRepo), ".env.example");
1569
1551
  if (credential) {
1570
1552
  const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
1571
1553
  record(report, writeCollisionSafe(helperPath, targetRepo, generateMcpHeadersHelper()), MCP_HEADERS_HELPER_REL);
@@ -1613,7 +1595,7 @@ async function install(options) {
1613
1595
  case "absent":
1614
1596
  break;
1615
1597
  }
1616
- writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
1598
+ writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
1617
1599
  writeCrewRoster(targetRepo, report);
1618
1600
  const scanResult = runBootstrapScan(targetRepo);
1619
1601
  return {
@@ -1639,126 +1621,6 @@ function controlOriginNote(resolved) {
1639
1621
  return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane)`;
1640
1622
  }
1641
1623
 
1642
- // dist/create-engagement.js
1643
- var CreateEngagementRefused = class extends Error {
1644
- status;
1645
- serverMessage;
1646
- errorCode;
1647
- constructor(status, serverMessage, errorCode, message) {
1648
- super(message);
1649
- this.status = status;
1650
- this.serverMessage = serverMessage;
1651
- this.errorCode = errorCode;
1652
- this.name = "CreateEngagementRefused";
1653
- }
1654
- /**
1655
- * Is this a refusal of the CALLER's credential — the arm signing in again can fix?
1656
- *
1657
- * `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
1658
- * the whole reason this is a method rather than a status comparison at the call
1659
- * site: the flag gates the DEPLOYMENT, identically for every caller, so no
1660
- * credential and no sign-in changes the answer. Treating it as an auth refusal
1661
- * would send a developer through a browser handoff to arrive at the same 403.
1662
- *
1663
- * **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
1664
- * (T-24).** On the join route a 403 has two causes that share a status AND a
1665
- * discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
1666
- * apart: the credential may be a live engagement token carrying no account, or the
1667
- * engagement id may not resolve. Signing in again cannot fix the second, and
1668
- * treating it as though it could would discard a good credential, walk the
1669
- * developer through a browser, and arrive at the identical 403 — a remedy that
1670
- * loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
1671
- * where the reason can be written down. See `joinPinnedEngagement` there.
1672
- */
1673
- get authRefused() {
1674
- if (this.errorCode === "PublicStartDisabled")
1675
- return false;
1676
- return this.status === 401 || this.status === 403;
1677
- }
1678
- };
1679
- function planeRefusal(detail) {
1680
- let body;
1681
- try {
1682
- body = JSON.parse(detail);
1683
- } catch {
1684
- return void 0;
1685
- }
1686
- if (typeof body !== "object" || body === null)
1687
- return void 0;
1688
- const candidate = body;
1689
- if (typeof candidate.message !== "string" || candidate.message.trim() === "")
1690
- return void 0;
1691
- const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
1692
- if (!hasDiscriminant)
1693
- return void 0;
1694
- return {
1695
- message: candidate.message,
1696
- error: typeof candidate.error === "string" ? candidate.error : void 0
1697
- };
1698
- }
1699
- 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.`;
1700
- async function createEngagement(baseUrl, name, credential) {
1701
- return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
1702
- }
1703
- async function joinEngagement(baseUrl, engagementId, credential) {
1704
- return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
1705
- action: "Join-engagement",
1706
- nothingHappened: "No credential was minted",
1707
- route: "POST /engagements/:engagementId/join"
1708
- });
1709
- }
1710
- async function requestEngagementValues(url, requestBody, credential, shape) {
1711
- const bearer = credential?.trim();
1712
- let res;
1713
- try {
1714
- res = await fetch(url, {
1715
- method: "POST",
1716
- headers: {
1717
- "content-type": "application/json",
1718
- ...bearer ? { authorization: `Bearer ${bearer}` } : {}
1719
- },
1720
- body: JSON.stringify(requestBody)
1721
- });
1722
- } catch (err) {
1723
- 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);
1724
- }
1725
- if (!res.ok) {
1726
- const detail = await res.text().catch(() => "");
1727
- const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
1728
- if (refusal) {
1729
- throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
1730
- }
1731
- const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
1732
- throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1733
- }
1734
- const body = await res.json().catch(() => null);
1735
- if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
1736
- throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1737
- }
1738
- for (const [field, value] of [
1739
- ["engagementId", body.engagementId],
1740
- ["sessionToken", body.sessionToken]
1741
- ]) {
1742
- if (value.trim() === "") {
1743
- 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);
1744
- }
1745
- }
1746
- if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
1747
- 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.`);
1748
- }
1749
- if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
1750
- 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.`);
1751
- }
1752
- const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
1753
- return {
1754
- engagementId: body.engagementId,
1755
- sessionToken: body.sessionToken,
1756
- mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
1757
- guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
1758
- ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
1759
- };
1760
- }
1761
-
1762
1624
  // dist/device-signin.js
1763
1625
  import { spawn } from "node:child_process";
1764
1626
  import { platform as platform3 } from "node:os";
@@ -1769,8 +1631,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
1769
1631
  var LOOPBACK_PORT_ENV = "HALFCYCLE_LOOPBACK_PORT";
1770
1632
  var DEFAULT_STILL_WAITING_MS = 15e3;
1771
1633
  function completionPage(result) {
1772
- 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.";
1773
- 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>`;
1774
1638
  }
1775
1639
  function isLoopbackPeer(remoteAddress) {
1776
1640
  if (remoteAddress === void 0)
@@ -1841,14 +1705,14 @@ async function bindLoopback(env = process.env) {
1841
1705
  else
1842
1706
  pending = callback;
1843
1707
  }));
1844
- const bindProblem = await new Promise((resolve2) => {
1708
+ const bindProblem = await new Promise((resolve3) => {
1845
1709
  const onError = (err) => {
1846
- resolve2(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
1710
+ resolve3(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
1847
1711
  };
1848
1712
  server.once("error", onError);
1849
1713
  server.listen({ host: LOOPBACK_REDIRECT_HOST, port: wanted.port }, () => {
1850
1714
  server.removeListener("error", onError);
1851
- resolve2(null);
1715
+ resolve3(null);
1852
1716
  });
1853
1717
  });
1854
1718
  if (bindProblem !== null) {
@@ -1864,9 +1728,9 @@ async function bindLoopback(env = process.env) {
1864
1728
  bound: true,
1865
1729
  target: { port: address.port, state },
1866
1730
  boundAddress: address.address,
1867
- waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve2) => {
1731
+ waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve3) => {
1868
1732
  if (pending !== null) {
1869
- resolve2(pending);
1733
+ resolve3(pending);
1870
1734
  return;
1871
1735
  }
1872
1736
  const startedAt = Date.now();
@@ -1876,7 +1740,7 @@ async function bindLoopback(env = process.env) {
1876
1740
  clearInterval(ticker);
1877
1741
  clearTimeout(timer);
1878
1742
  deliver = null;
1879
- resolve2(value);
1743
+ resolve3(value);
1880
1744
  };
1881
1745
  const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
1882
1746
  deliver = finish;
@@ -1885,9 +1749,9 @@ async function bindLoopback(env = process.env) {
1885
1749
  };
1886
1750
  }
1887
1751
  function closeServer(server) {
1888
- return new Promise((resolve2) => {
1752
+ return new Promise((resolve3) => {
1889
1753
  server.closeAllConnections();
1890
- server.close(() => resolve2());
1754
+ server.close(() => resolve3());
1891
1755
  });
1892
1756
  }
1893
1757
 
@@ -1909,7 +1773,7 @@ function defaultWrite(text) {
1909
1773
  process.stdout.write(text);
1910
1774
  }
1911
1775
  function defaultSleep(ms) {
1912
- return new Promise((resolve2) => setTimeout(resolve2, ms));
1776
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1913
1777
  }
1914
1778
  function browserOpenCommand(os, url) {
1915
1779
  if (os === "darwin")
@@ -1935,12 +1799,12 @@ async function openBrowser(url, env = process.env) {
1935
1799
  return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
1936
1800
  }
1937
1801
  const { command, args: args2 } = browserOpenCommand(platform3(), url);
1938
- return new Promise((resolve2) => {
1802
+ return new Promise((resolve3) => {
1939
1803
  let child;
1940
1804
  try {
1941
1805
  child = spawn(command, args2, { stdio: "ignore", env });
1942
1806
  } catch (err) {
1943
- resolve2({
1807
+ resolve3({
1944
1808
  opened: false,
1945
1809
  reason: `${command} could not be run (${err instanceof Error ? err.message : String(err)})`
1946
1810
  });
@@ -1952,7 +1816,7 @@ async function openBrowser(url, env = process.env) {
1952
1816
  return;
1953
1817
  settled = true;
1954
1818
  clearTimeout(timer);
1955
- resolve2(attempt);
1819
+ resolve3(attempt);
1956
1820
  };
1957
1821
  const timer = setTimeout(() => {
1958
1822
  child.unref();
@@ -2172,6 +2036,134 @@ async function deviceCodeSignIn(serviceUrl, started, env, deps) {
2172
2036
  };
2173
2037
  }
2174
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
+
2175
2167
  // dist/resolve-credential.js
2176
2168
  async function obtainCredential(serviceUrl, opts = {}) {
2177
2169
  const env = opts.env ?? process.env;
@@ -2180,8 +2172,16 @@ async function obtainCredential(serviceUrl, opts = {}) {
2180
2172
  return { credential: fromEnv, source: "environment" };
2181
2173
  const stored = readStoredCredential(serviceUrl, opts.home);
2182
2174
  if (stored) {
2183
- 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
+ };
2184
2181
  }
2182
+ return signInAndStore(serviceUrl, opts);
2183
+ }
2184
+ async function signInAndStore(serviceUrl, opts) {
2185
2185
  const signedIn = await signIn(serviceUrl, opts);
2186
2186
  writeStoredCredential(serviceUrl, {
2187
2187
  accountId: signedIn.accountId,
@@ -2194,21 +2194,15 @@ async function obtainCredential(serviceUrl, opts = {}) {
2194
2194
  accountId: signedIn.accountId
2195
2195
  };
2196
2196
  }
2197
+ async function switchAccount(serviceUrl, opts = {}) {
2198
+ forgetStoredCredential(serviceUrl, opts.home);
2199
+ return signInAndStore(serviceUrl, opts);
2200
+ }
2197
2201
  async function replaceRefusedCredential(serviceUrl, refused, opts = {}) {
2198
2202
  if (refused.source !== "stored")
2199
2203
  return null;
2200
2204
  forgetStoredCredential(serviceUrl, opts.home);
2201
- const signedIn = await signIn(serviceUrl, opts);
2202
- writeStoredCredential(serviceUrl, {
2203
- accountId: signedIn.accountId,
2204
- credential: signedIn.credential,
2205
- expiresAt: signedIn.expiresAt
2206
- }, opts.home);
2207
- return {
2208
- credential: signedIn.credential,
2209
- source: "browser-handoff",
2210
- accountId: signedIn.accountId
2211
- };
2205
+ return signInAndStore(serviceUrl, opts);
2212
2206
  }
2213
2207
  function refusedCredentialRemedy(source) {
2214
2208
  switch (source) {
@@ -2221,6 +2215,238 @@ function refusedCredentialRemedy(source) {
2221
2215
  }
2222
2216
  }
2223
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
+
2224
2450
  // dist/mint-board-code.js
2225
2451
  async function mintBoardEnterCode(baseUrl, sessionToken) {
2226
2452
  const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
@@ -2553,14 +2779,14 @@ function renderBuildRecordMarkdown(record2) {
2553
2779
 
2554
2780
  // dist/build-record/write.js
2555
2781
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
2556
- 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";
2557
2783
  var BUILD_RECORD_DIR = join7("docs", "build-records");
2558
2784
  function serialiseRecordJson(record2) {
2559
2785
  return JSON.stringify(record2, null, 2) + "\n";
2560
2786
  }
2561
2787
  function writeBuildRecord(repoRoot, record2) {
2562
- const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
2563
- const expected = resolve(repoRoot, BUILD_RECORD_DIR);
2788
+ const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
2789
+ const expected = resolve2(repoRoot, BUILD_RECORD_DIR);
2564
2790
  const rel = relative2(expected, outDir);
2565
2791
  if (rel !== "" || isAbsolute(rel)) {
2566
2792
  throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
@@ -2834,7 +3060,10 @@ var PLACEHOLDERS = {
2834
3060
  };
2835
3061
 
2836
3062
  // dist/bin.js
2837
- 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));
2838
3067
  var MIN_HEADERS_HELPER_VERSION = "2.1.118";
2839
3068
  var MIN_HEADER_ROTATION_VERSION = "2.1.193";
2840
3069
  function compareVersions(a, b) {
@@ -2873,6 +3102,7 @@ function reportClaudeCodeVersion() {
2873
3102
  }
2874
3103
  var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|client]
2875
3104
  install into target (default: the current directory)
3105
+ --yes / -y: use this machine's saved sign-in without being asked
2876
3106
  halfcycle check-drift <target-repo>
2877
3107
  halfcycle build-record <phase-id> [--repo <root>]
2878
3108
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
@@ -2886,49 +3116,6 @@ function isHalfcycleMonorepo(dir) {
2886
3116
  return false;
2887
3117
  }
2888
3118
  }
2889
- async function createOwnedEngagement(serviceUrl) {
2890
- let credential = await obtainCredential(serviceUrl);
2891
- for (; ; ) {
2892
- try {
2893
- return await createEngagement(serviceUrl, void 0, credential.credential);
2894
- } catch (err) {
2895
- if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
2896
- throw err;
2897
- const replacement = await replaceRefusedCredential(serviceUrl, credential);
2898
- if (replacement === null) {
2899
- throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
2900
- }
2901
- process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2902
- `);
2903
- credential = replacement;
2904
- }
2905
- }
2906
- }
2907
- 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.";
2908
- async function joinPinnedEngagement(serviceUrl, engagementId) {
2909
- let credential = await obtainCredential(serviceUrl);
2910
- for (; ; ) {
2911
- try {
2912
- return await joinEngagement(serviceUrl, engagementId, credential.credential);
2913
- } catch (err) {
2914
- if (!(err instanceof CreateEngagementRefused))
2915
- throw err;
2916
- if (err.status === 403) {
2917
- throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
2918
- }
2919
- if (err.status !== 401) {
2920
- throw err;
2921
- }
2922
- const replacement = await replaceRefusedCredential(serviceUrl, credential);
2923
- if (replacement === null) {
2924
- throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
2925
- }
2926
- process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2927
- `);
2928
- credential = replacement;
2929
- }
2930
- }
2931
- }
2932
3119
  async function main() {
2933
3120
  const [cmd, ...rest] = args;
2934
3121
  if (cmd === "--help" || cmd === "-h" || cmd === "help") {
@@ -2971,6 +3158,7 @@ ${USAGE}`);
2971
3158
  const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
2972
3159
  let engagementId;
2973
3160
  let credential;
3161
+ let actingAccountId;
2974
3162
  if (reusable !== void 0 && pinned !== null) {
2975
3163
  engagementId = pinned.engagementId;
2976
3164
  credential = reusable;
@@ -2984,8 +3172,9 @@ ${USAGE}`);
2984
3172
  `);
2985
3173
  }
2986
3174
  } else if (requestedId !== void 0) {
2987
- const joined = await joinPinnedEngagement(serviceUrl, requestedId);
3175
+ const joined = await joinPinnedEngagement(serviceUrl, requestedId, targetRepo, { assumeYes });
2988
3176
  engagementId = joined.engagementId;
3177
+ actingAccountId = joined.actingAccountId;
2989
3178
  credential = {
2990
3179
  serviceUrl,
2991
3180
  token: joined.sessionToken,
@@ -2999,8 +3188,9 @@ ${USAGE}`);
2999
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.
3000
3189
  `);
3001
3190
  } else {
3002
- const created = await createOwnedEngagement(serviceUrl);
3191
+ const created = await createOwnedEngagement(serviceUrl, targetRepo, { assumeYes });
3003
3192
  engagementId = created.engagementId;
3193
+ actingAccountId = created.actingAccountId;
3004
3194
  credential = {
3005
3195
  serviceUrl,
3006
3196
  token: created.sessionToken,
@@ -3021,7 +3211,8 @@ ${USAGE}`);
3021
3211
  targetRepo,
3022
3212
  engagementId,
3023
3213
  engagementType: engagementTypeRaw,
3024
- credential
3214
+ credential,
3215
+ accountId: actingAccountId
3025
3216
  });
3026
3217
  process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
3027
3218
  `);