halfcycle 0.3.11 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/banner-facts.d.ts +73 -0
- package/dist/banner-facts.d.ts.map +1 -0
- package/dist/banner.d.ts +188 -0
- package/dist/banner.d.ts.map +1 -0
- package/dist/bin.js +855 -236
- package/dist/bin.js.map +3 -3
- package/dist/confirm-identity.d.ts +176 -0
- package/dist/confirm-identity.d.ts.map +1 -0
- package/dist/create-engagement.d.ts +12 -2
- package/dist/create-engagement.d.ts.map +1 -1
- package/dist/identity.d.ts +10 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.js +26 -6
- package/dist/index.js.map +2 -2
- package/dist/install.d.ts +42 -0
- package/dist/install.d.ts.map +1 -1
- package/dist/loopback-signin.d.ts.map +1 -1
- package/dist/own-engagement.d.ts +161 -0
- package/dist/own-engagement.d.ts.map +1 -0
- package/dist/project-name.d.ts +52 -0
- package/dist/project-name.d.ts.map +1 -0
- package/dist/resolve-credential.d.ts +52 -4
- package/dist/resolve-credential.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// dist/bin.js
|
|
4
|
-
import { execFileSync as
|
|
5
|
-
import { readFileSync as
|
|
6
|
-
import { join as
|
|
4
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
5
|
+
import { readFileSync as readFileSync9 } from "node:fs";
|
|
6
|
+
import { join as join10 } from "node:path";
|
|
7
7
|
|
|
8
8
|
// dist/install.js
|
|
9
9
|
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
@@ -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";
|
|
@@ -693,6 +704,13 @@ function readRootCommit(targetRepoRoot) {
|
|
|
693
704
|
function readRemote(targetRepoRoot) {
|
|
694
705
|
return git(targetRepoRoot, ["remote", "get-url", "origin"]);
|
|
695
706
|
}
|
|
707
|
+
function readCommitCount(targetRepoRoot) {
|
|
708
|
+
const out = git(targetRepoRoot, ["rev-list", "--count", "HEAD"]);
|
|
709
|
+
if (out === null)
|
|
710
|
+
return null;
|
|
711
|
+
const count = Number.parseInt(out, 10);
|
|
712
|
+
return Number.isFinite(count) ? count : null;
|
|
713
|
+
}
|
|
696
714
|
function mintOrReadIdentity(targetRepoRoot) {
|
|
697
715
|
const path = join3(targetRepoRoot, ".halfcycle", "project.json");
|
|
698
716
|
if (existsSync(path)) {
|
|
@@ -1323,12 +1341,24 @@ ${header}
|
|
|
1323
1341
|
return "failed";
|
|
1324
1342
|
}
|
|
1325
1343
|
}
|
|
1326
|
-
function
|
|
1344
|
+
function previousAccountId(targetRepoRoot, engagementId) {
|
|
1345
|
+
try {
|
|
1346
|
+
const existing = readBundlePin(targetRepoRoot);
|
|
1347
|
+
if (existing === null || existing.engagementId !== engagementId)
|
|
1348
|
+
return void 0;
|
|
1349
|
+
return existing.accountId;
|
|
1350
|
+
} catch {
|
|
1351
|
+
return void 0;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
|
|
1355
|
+
const carried = accountId ?? previousAccountId(targetRepoRoot, engagementId);
|
|
1327
1356
|
const pin = {
|
|
1328
1357
|
version,
|
|
1329
1358
|
engagementId,
|
|
1330
1359
|
engagementType,
|
|
1331
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1360
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1361
|
+
...carried !== void 0 && carried.trim() !== "" ? { accountId: carried } : {}
|
|
1332
1362
|
};
|
|
1333
1363
|
const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
|
|
1334
1364
|
writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
|
|
@@ -1483,7 +1513,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
|
|
|
1483
1513
|
}
|
|
1484
1514
|
}
|
|
1485
1515
|
async function install(options) {
|
|
1486
|
-
const { targetRepo, engagementId, engagementType, credential, home } = options;
|
|
1516
|
+
const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
|
|
1487
1517
|
if (!existsSync3(targetRepo)) {
|
|
1488
1518
|
throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
|
|
1489
1519
|
}
|
|
@@ -1572,7 +1602,7 @@ async function install(options) {
|
|
|
1572
1602
|
case "absent":
|
|
1573
1603
|
break;
|
|
1574
1604
|
}
|
|
1575
|
-
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
|
|
1605
|
+
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
|
|
1576
1606
|
writeCrewRoster(targetRepo, report);
|
|
1577
1607
|
const scanResult = runBootstrapScan(targetRepo);
|
|
1578
1608
|
return {
|
|
@@ -1598,126 +1628,6 @@ function controlOriginNote(resolved) {
|
|
|
1598
1628
|
return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane)`;
|
|
1599
1629
|
}
|
|
1600
1630
|
|
|
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
1631
|
// dist/device-signin.js
|
|
1722
1632
|
import { spawn } from "node:child_process";
|
|
1723
1633
|
import { platform as platform3 } from "node:os";
|
|
@@ -1728,8 +1638,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
1728
1638
|
var LOOPBACK_PORT_ENV = "HALFCYCLE_LOOPBACK_PORT";
|
|
1729
1639
|
var DEFAULT_STILL_WAITING_MS = 15e3;
|
|
1730
1640
|
function completionPage(result) {
|
|
1731
|
-
const
|
|
1732
|
-
return
|
|
1641
|
+
const approved = result === LOOPBACK_RESULT.APPROVED;
|
|
1642
|
+
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.";
|
|
1643
|
+
const successLink = approved ? `<p><a href="${DASHBOARD_URL}" style="color:#c8a24b">Go to your projects</a></p>` : "";
|
|
1644
|
+
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
1645
|
}
|
|
1734
1646
|
function isLoopbackPeer(remoteAddress) {
|
|
1735
1647
|
if (remoteAddress === void 0)
|
|
@@ -1800,14 +1712,14 @@ async function bindLoopback(env = process.env) {
|
|
|
1800
1712
|
else
|
|
1801
1713
|
pending = callback;
|
|
1802
1714
|
}));
|
|
1803
|
-
const bindProblem = await new Promise((
|
|
1715
|
+
const bindProblem = await new Promise((resolve4) => {
|
|
1804
1716
|
const onError = (err) => {
|
|
1805
|
-
|
|
1717
|
+
resolve4(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
|
|
1806
1718
|
};
|
|
1807
1719
|
server.once("error", onError);
|
|
1808
1720
|
server.listen({ host: LOOPBACK_REDIRECT_HOST, port: wanted.port }, () => {
|
|
1809
1721
|
server.removeListener("error", onError);
|
|
1810
|
-
|
|
1722
|
+
resolve4(null);
|
|
1811
1723
|
});
|
|
1812
1724
|
});
|
|
1813
1725
|
if (bindProblem !== null) {
|
|
@@ -1823,9 +1735,9 @@ async function bindLoopback(env = process.env) {
|
|
|
1823
1735
|
bound: true,
|
|
1824
1736
|
target: { port: address.port, state },
|
|
1825
1737
|
boundAddress: address.address,
|
|
1826
|
-
waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((
|
|
1738
|
+
waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve4) => {
|
|
1827
1739
|
if (pending !== null) {
|
|
1828
|
-
|
|
1740
|
+
resolve4(pending);
|
|
1829
1741
|
return;
|
|
1830
1742
|
}
|
|
1831
1743
|
const startedAt = Date.now();
|
|
@@ -1835,7 +1747,7 @@ async function bindLoopback(env = process.env) {
|
|
|
1835
1747
|
clearInterval(ticker);
|
|
1836
1748
|
clearTimeout(timer);
|
|
1837
1749
|
deliver = null;
|
|
1838
|
-
|
|
1750
|
+
resolve4(value);
|
|
1839
1751
|
};
|
|
1840
1752
|
const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
|
|
1841
1753
|
deliver = finish;
|
|
@@ -1844,9 +1756,9 @@ async function bindLoopback(env = process.env) {
|
|
|
1844
1756
|
};
|
|
1845
1757
|
}
|
|
1846
1758
|
function closeServer(server) {
|
|
1847
|
-
return new Promise((
|
|
1759
|
+
return new Promise((resolve4) => {
|
|
1848
1760
|
server.closeAllConnections();
|
|
1849
|
-
server.close(() =>
|
|
1761
|
+
server.close(() => resolve4());
|
|
1850
1762
|
});
|
|
1851
1763
|
}
|
|
1852
1764
|
|
|
@@ -1868,7 +1780,7 @@ function defaultWrite(text) {
|
|
|
1868
1780
|
process.stdout.write(text);
|
|
1869
1781
|
}
|
|
1870
1782
|
function defaultSleep(ms) {
|
|
1871
|
-
return new Promise((
|
|
1783
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
1872
1784
|
}
|
|
1873
1785
|
function browserOpenCommand(os, url) {
|
|
1874
1786
|
if (os === "darwin")
|
|
@@ -1894,12 +1806,12 @@ async function openBrowser(url, env = process.env) {
|
|
|
1894
1806
|
return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
|
|
1895
1807
|
}
|
|
1896
1808
|
const { command, args: args2 } = browserOpenCommand(platform3(), url);
|
|
1897
|
-
return new Promise((
|
|
1809
|
+
return new Promise((resolve4) => {
|
|
1898
1810
|
let child;
|
|
1899
1811
|
try {
|
|
1900
1812
|
child = spawn(command, args2, { stdio: "ignore", env });
|
|
1901
1813
|
} catch (err) {
|
|
1902
|
-
|
|
1814
|
+
resolve4({
|
|
1903
1815
|
opened: false,
|
|
1904
1816
|
reason: `${command} could not be run (${err instanceof Error ? err.message : String(err)})`
|
|
1905
1817
|
});
|
|
@@ -1911,7 +1823,7 @@ async function openBrowser(url, env = process.env) {
|
|
|
1911
1823
|
return;
|
|
1912
1824
|
settled = true;
|
|
1913
1825
|
clearTimeout(timer);
|
|
1914
|
-
|
|
1826
|
+
resolve4(attempt);
|
|
1915
1827
|
};
|
|
1916
1828
|
const timer = setTimeout(() => {
|
|
1917
1829
|
child.unref();
|
|
@@ -2131,6 +2043,134 @@ async function deviceCodeSignIn(serviceUrl, started, env, deps) {
|
|
|
2131
2043
|
};
|
|
2132
2044
|
}
|
|
2133
2045
|
|
|
2046
|
+
// dist/create-engagement.js
|
|
2047
|
+
var CreateEngagementRefused = class extends Error {
|
|
2048
|
+
status;
|
|
2049
|
+
serverMessage;
|
|
2050
|
+
errorCode;
|
|
2051
|
+
constructor(status, serverMessage, errorCode, message) {
|
|
2052
|
+
super(message);
|
|
2053
|
+
this.status = status;
|
|
2054
|
+
this.serverMessage = serverMessage;
|
|
2055
|
+
this.errorCode = errorCode;
|
|
2056
|
+
this.name = "CreateEngagementRefused";
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Is this a refusal of the CALLER's credential — the arm signing in again can fix?
|
|
2060
|
+
*
|
|
2061
|
+
* `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
|
|
2062
|
+
* the whole reason this is a method rather than a status comparison at the call
|
|
2063
|
+
* site: the flag gates the DEPLOYMENT, identically for every caller, so no
|
|
2064
|
+
* credential and no sign-in changes the answer. Treating it as an auth refusal
|
|
2065
|
+
* would send a developer through a browser handoff to arrive at the same 403.
|
|
2066
|
+
*
|
|
2067
|
+
* **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
|
|
2068
|
+
* (T-24).** On the join route a 403 has two causes that share a status AND a
|
|
2069
|
+
* discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
|
|
2070
|
+
* apart: the credential may be a live engagement token carrying no account, or the
|
|
2071
|
+
* engagement id may not resolve. Signing in again cannot fix the second, and
|
|
2072
|
+
* treating it as though it could would discard a good credential, walk the
|
|
2073
|
+
* developer through a browser, and arrive at the identical 403 — a remedy that
|
|
2074
|
+
* loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
|
|
2075
|
+
* where the reason can be written down. See `joinPinnedEngagement` there.
|
|
2076
|
+
*/
|
|
2077
|
+
get authRefused() {
|
|
2078
|
+
if (this.errorCode === "PublicStartDisabled")
|
|
2079
|
+
return false;
|
|
2080
|
+
return this.status === 401 || this.status === 403;
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
function planeRefusal(detail) {
|
|
2084
|
+
let body;
|
|
2085
|
+
try {
|
|
2086
|
+
body = JSON.parse(detail);
|
|
2087
|
+
} catch {
|
|
2088
|
+
return void 0;
|
|
2089
|
+
}
|
|
2090
|
+
if (typeof body !== "object" || body === null)
|
|
2091
|
+
return void 0;
|
|
2092
|
+
const candidate = body;
|
|
2093
|
+
if (typeof candidate.message !== "string" || candidate.message.trim() === "")
|
|
2094
|
+
return void 0;
|
|
2095
|
+
const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
|
|
2096
|
+
if (!hasDiscriminant)
|
|
2097
|
+
return void 0;
|
|
2098
|
+
return {
|
|
2099
|
+
message: candidate.message,
|
|
2100
|
+
error: typeof candidate.error === "string" ? candidate.error : void 0
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
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.`;
|
|
2104
|
+
async function createEngagement(baseUrl, name, credential, derivedName) {
|
|
2105
|
+
return requestEngagementValues(
|
|
2106
|
+
`${baseUrl.replace(/\/+$/, "")}/engagements`,
|
|
2107
|
+
// A stated name wins over a guess, and the body carries at most one of them —
|
|
2108
|
+
// the plane applies the same ordering, so the two cannot disagree about which
|
|
2109
|
+
// one a row was named from.
|
|
2110
|
+
name ? { name } : derivedName ? { derivedName } : {},
|
|
2111
|
+
credential,
|
|
2112
|
+
{ action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" }
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
async function joinEngagement(baseUrl, engagementId, credential) {
|
|
2116
|
+
return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
|
|
2117
|
+
action: "Join-engagement",
|
|
2118
|
+
nothingHappened: "No credential was minted",
|
|
2119
|
+
route: "POST /engagements/:engagementId/join"
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
2123
|
+
const bearer = credential?.trim();
|
|
2124
|
+
let res;
|
|
2125
|
+
try {
|
|
2126
|
+
res = await fetch(url, {
|
|
2127
|
+
method: "POST",
|
|
2128
|
+
headers: {
|
|
2129
|
+
"content-type": "application/json",
|
|
2130
|
+
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
2131
|
+
},
|
|
2132
|
+
body: JSON.stringify(requestBody)
|
|
2133
|
+
});
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
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);
|
|
2136
|
+
}
|
|
2137
|
+
if (!res.ok) {
|
|
2138
|
+
const detail = await res.text().catch(() => "");
|
|
2139
|
+
const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
|
|
2140
|
+
if (refusal) {
|
|
2141
|
+
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
|
|
2142
|
+
}
|
|
2143
|
+
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
|
|
2144
|
+
throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2145
|
+
}
|
|
2146
|
+
const body = await res.json().catch(() => null);
|
|
2147
|
+
if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
|
|
2148
|
+
throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2149
|
+
}
|
|
2150
|
+
for (const [field, value] of [
|
|
2151
|
+
["engagementId", body.engagementId],
|
|
2152
|
+
["sessionToken", body.sessionToken]
|
|
2153
|
+
]) {
|
|
2154
|
+
if (value.trim() === "") {
|
|
2155
|
+
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);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
|
|
2159
|
+
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.`);
|
|
2160
|
+
}
|
|
2161
|
+
if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
|
|
2162
|
+
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.`);
|
|
2163
|
+
}
|
|
2164
|
+
const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
|
|
2165
|
+
return {
|
|
2166
|
+
engagementId: body.engagementId,
|
|
2167
|
+
sessionToken: body.sessionToken,
|
|
2168
|
+
mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
|
|
2169
|
+
guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
|
|
2170
|
+
...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2134
2174
|
// dist/resolve-credential.js
|
|
2135
2175
|
async function obtainCredential(serviceUrl, opts = {}) {
|
|
2136
2176
|
const env = opts.env ?? process.env;
|
|
@@ -2139,8 +2179,16 @@ async function obtainCredential(serviceUrl, opts = {}) {
|
|
|
2139
2179
|
return { credential: fromEnv, source: "environment" };
|
|
2140
2180
|
const stored = readStoredCredential(serviceUrl, opts.home);
|
|
2141
2181
|
if (stored) {
|
|
2142
|
-
return {
|
|
2182
|
+
return {
|
|
2183
|
+
credential: stored.credential,
|
|
2184
|
+
source: "stored",
|
|
2185
|
+
accountId: stored.accountId,
|
|
2186
|
+
signedInAt: stored.signedInAt
|
|
2187
|
+
};
|
|
2143
2188
|
}
|
|
2189
|
+
return signInAndStore(serviceUrl, opts);
|
|
2190
|
+
}
|
|
2191
|
+
async function signInAndStore(serviceUrl, opts) {
|
|
2144
2192
|
const signedIn = await signIn(serviceUrl, opts);
|
|
2145
2193
|
writeStoredCredential(serviceUrl, {
|
|
2146
2194
|
accountId: signedIn.accountId,
|
|
@@ -2153,21 +2201,15 @@ async function obtainCredential(serviceUrl, opts = {}) {
|
|
|
2153
2201
|
accountId: signedIn.accountId
|
|
2154
2202
|
};
|
|
2155
2203
|
}
|
|
2204
|
+
async function switchAccount(serviceUrl, opts = {}) {
|
|
2205
|
+
forgetStoredCredential(serviceUrl, opts.home);
|
|
2206
|
+
return signInAndStore(serviceUrl, opts);
|
|
2207
|
+
}
|
|
2156
2208
|
async function replaceRefusedCredential(serviceUrl, refused, opts = {}) {
|
|
2157
2209
|
if (refused.source !== "stored")
|
|
2158
2210
|
return null;
|
|
2159
2211
|
forgetStoredCredential(serviceUrl, opts.home);
|
|
2160
|
-
|
|
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
|
-
};
|
|
2212
|
+
return signInAndStore(serviceUrl, opts);
|
|
2171
2213
|
}
|
|
2172
2214
|
function refusedCredentialRemedy(source) {
|
|
2173
2215
|
switch (source) {
|
|
@@ -2180,6 +2222,238 @@ function refusedCredentialRemedy(source) {
|
|
|
2180
2222
|
}
|
|
2181
2223
|
}
|
|
2182
2224
|
|
|
2225
|
+
// dist/confirm-identity.js
|
|
2226
|
+
import { createInterface } from "node:readline/promises";
|
|
2227
|
+
var IDENTITY_TIMEOUT_MS = 5e3;
|
|
2228
|
+
var IDENTITY_DECLINED = "identity-declined";
|
|
2229
|
+
async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
|
|
2230
|
+
const url = `${serviceUrl.replace(/\/+$/, "")}/account`;
|
|
2231
|
+
const controller = new AbortController();
|
|
2232
|
+
const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
|
|
2233
|
+
try {
|
|
2234
|
+
const res = await fetchImpl(url, {
|
|
2235
|
+
headers: { authorization: `Bearer ${credential}` },
|
|
2236
|
+
signal: controller.signal
|
|
2237
|
+
});
|
|
2238
|
+
if (!res.ok)
|
|
2239
|
+
return void 0;
|
|
2240
|
+
const parsed = safeParseAccountIdentity(await res.json());
|
|
2241
|
+
return parsed.success ? parsed.data : void 0;
|
|
2242
|
+
} catch {
|
|
2243
|
+
return void 0;
|
|
2244
|
+
} finally {
|
|
2245
|
+
clearTimeout(timer);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
function accountLabel(identity, storedAccountId) {
|
|
2249
|
+
const accountId = identity?.accountId ?? storedAccountId;
|
|
2250
|
+
if (identity?.email !== void 0 && identity.email.trim() !== "") {
|
|
2251
|
+
return accountId !== void 0 ? `${identity.email} (account ${accountId})` : identity.email;
|
|
2252
|
+
}
|
|
2253
|
+
if (accountId !== void 0)
|
|
2254
|
+
return `account ${accountId} (no email on record for it)`;
|
|
2255
|
+
return "the account this credential belongs to, which this plane did not name";
|
|
2256
|
+
}
|
|
2257
|
+
function onlyDate(iso) {
|
|
2258
|
+
if (iso === void 0)
|
|
2259
|
+
return void 0;
|
|
2260
|
+
const match = /^(\d{4}-\d{2}-\d{2})/.exec(iso);
|
|
2261
|
+
return match?.[1] ?? iso;
|
|
2262
|
+
}
|
|
2263
|
+
function actClause(act) {
|
|
2264
|
+
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}`;
|
|
2265
|
+
}
|
|
2266
|
+
async function defaultAsk(question) {
|
|
2267
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2268
|
+
try {
|
|
2269
|
+
return await rl.question(question);
|
|
2270
|
+
} finally {
|
|
2271
|
+
rl.close();
|
|
2272
|
+
process.stdin.pause();
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
function readDecision(answer) {
|
|
2276
|
+
const said = answer.trim().toLowerCase();
|
|
2277
|
+
if (said === "y" || said === "yes")
|
|
2278
|
+
return "proceed";
|
|
2279
|
+
if (said === "s" || said === "switch")
|
|
2280
|
+
return "switch-account";
|
|
2281
|
+
return "stop";
|
|
2282
|
+
}
|
|
2283
|
+
function actingAs(credential, identity) {
|
|
2284
|
+
const accountId = identity?.accountId ?? credential.accountId;
|
|
2285
|
+
return accountId === void 0 ? credential : { ...credential, accountId };
|
|
2286
|
+
}
|
|
2287
|
+
function armScript(credential, act, label, home) {
|
|
2288
|
+
switch (credential.source) {
|
|
2289
|
+
case "browser-handoff":
|
|
2290
|
+
return {
|
|
2291
|
+
preamble: `[halfcycle] Signed in as ${label}. This install will ${actClause(act)}.
|
|
2292
|
+
`,
|
|
2293
|
+
asks: false,
|
|
2294
|
+
noTerminalNote: "",
|
|
2295
|
+
switchOpening: "",
|
|
2296
|
+
stopRemedy: "",
|
|
2297
|
+
afterSwitch: ""
|
|
2298
|
+
};
|
|
2299
|
+
case "environment":
|
|
2300
|
+
return {
|
|
2301
|
+
preamble: `[halfcycle] HALFCYCLE_TOKEN is set in this environment, and this install will use it.
|
|
2302
|
+
[halfcycle] Account: ${label}
|
|
2303
|
+
[halfcycle] About to ${actClause(act)}
|
|
2304
|
+
[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.
|
|
2305
|
+
`,
|
|
2306
|
+
asks: true,
|
|
2307
|
+
// Deliberately nothing. This is the arm's DESIGNED case — a run with no browser
|
|
2308
|
+
// and no human — the preamble has already named the account and the variable,
|
|
2309
|
+
// and a line about having nobody to ask would print on every CI job forever.
|
|
2310
|
+
noTerminalNote: "",
|
|
2311
|
+
switchOpening: `[halfcycle] Signing you in through your browser \u2014 this install will use that account instead.
|
|
2312
|
+
`,
|
|
2313
|
+
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.`,
|
|
2314
|
+
// The switch is real for THIS run (the new credential is what gets used) and it
|
|
2315
|
+
// does not persist: `obtainCredential` reads the variable first, so the next run
|
|
2316
|
+
// is the same variable again. Said here rather than discovered tomorrow.
|
|
2317
|
+
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.
|
|
2318
|
+
`
|
|
2319
|
+
};
|
|
2320
|
+
case "stored": {
|
|
2321
|
+
const savedOn = onlyDate(credential.signedInAt);
|
|
2322
|
+
return {
|
|
2323
|
+
preamble: `[halfcycle] This machine already has a Halfcycle sign-in saved, and this install will use it.
|
|
2324
|
+
[halfcycle] Account: ${label}
|
|
2325
|
+
` + (savedOn !== void 0 ? `[halfcycle] Saved on this machine: ${savedOn}
|
|
2326
|
+
` : "") + `[halfcycle] About to ${actClause(act)}
|
|
2327
|
+
[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.
|
|
2328
|
+
`,
|
|
2329
|
+
asks: true,
|
|
2330
|
+
// Not a question nobody will read — a record of what happened, the two ways to
|
|
2331
|
+
// make the next run act as somebody else (both executable from exactly the state
|
|
2332
|
+
// this is printed in), and where to look for the answer once this output is gone.
|
|
2333
|
+
noTerminalNote: `[halfcycle] Nothing here to ask at (no terminal), so this is continuing as that account.
|
|
2334
|
+
[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.
|
|
2335
|
+
[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.
|
|
2336
|
+
`,
|
|
2337
|
+
switchOpening: `[halfcycle] Forgetting this machine's saved sign-in and signing you in through your browser.
|
|
2338
|
+
`,
|
|
2339
|
+
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.`,
|
|
2340
|
+
afterSwitch: ""
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
|
|
2346
|
+
const write = deps.write ?? ((text) => process.stdout.write(text));
|
|
2347
|
+
const identity = await describeAccount(serviceUrl, credential.credential, deps.fetchImpl);
|
|
2348
|
+
const label = accountLabel(identity, credential.accountId);
|
|
2349
|
+
const acting = actingAs(credential, identity);
|
|
2350
|
+
const script = armScript(credential, act, label, deps.home);
|
|
2351
|
+
write(script.preamble);
|
|
2352
|
+
if (!script.asks)
|
|
2353
|
+
return acting;
|
|
2354
|
+
if (deps.assumeYes === true) {
|
|
2355
|
+
write(`[halfcycle] Continuing as that account without asking, because --yes was given.
|
|
2356
|
+
`);
|
|
2357
|
+
return acting;
|
|
2358
|
+
}
|
|
2359
|
+
const interactive = deps.interactive ?? (process.stdin.isTTY === true && !isNonInteractive(deps.env));
|
|
2360
|
+
if (!interactive) {
|
|
2361
|
+
if (script.noTerminalNote !== "")
|
|
2362
|
+
write(script.noTerminalNote);
|
|
2363
|
+
return acting;
|
|
2364
|
+
}
|
|
2365
|
+
const ask = deps.ask ?? defaultAsk;
|
|
2366
|
+
const decision = readDecision(await ask(`[halfcycle] Continue as that account? [y] yes [s] sign in as someone else [anything else] stop: `));
|
|
2367
|
+
if (decision === "proceed")
|
|
2368
|
+
return acting;
|
|
2369
|
+
if (decision === "switch-account") {
|
|
2370
|
+
write(script.switchOpening);
|
|
2371
|
+
const replacement = await switchAccount(serviceUrl, deps);
|
|
2372
|
+
const replacementIdentity = await describeAccount(serviceUrl, replacement.credential, deps.fetchImpl);
|
|
2373
|
+
write(`[halfcycle] Signed in as ${accountLabel(replacementIdentity, replacement.accountId)}. This install will ${actClause(act)}.
|
|
2374
|
+
`);
|
|
2375
|
+
if (script.afterSwitch !== "")
|
|
2376
|
+
write(script.afterSwitch);
|
|
2377
|
+
return actingAs(replacement, replacementIdentity);
|
|
2378
|
+
}
|
|
2379
|
+
throw new SignInRefused(IDENTITY_DECLINED, script.stopRemedy);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// dist/project-name.js
|
|
2383
|
+
import { basename, resolve } from "node:path";
|
|
2384
|
+
var MAX_DERIVED_NAME_LEN = 100;
|
|
2385
|
+
var EMPTY_SEGMENTS = /* @__PURE__ */ new Set(["", ".", "..", "/", "\\"]);
|
|
2386
|
+
function repositoryNameFromRemote(remote) {
|
|
2387
|
+
const trimmed = remote.trim();
|
|
2388
|
+
if (trimmed.length === 0)
|
|
2389
|
+
return void 0;
|
|
2390
|
+
const withoutTrailingSlash = trimmed.replace(/\/+$/, "");
|
|
2391
|
+
const lastSeparator = Math.max(withoutTrailingSlash.lastIndexOf("/"), withoutTrailingSlash.lastIndexOf(":"));
|
|
2392
|
+
const segment = withoutTrailingSlash.slice(lastSeparator + 1);
|
|
2393
|
+
const name = segment.replace(/\.git$/i, "").trim();
|
|
2394
|
+
return name.length > 0 ? name : void 0;
|
|
2395
|
+
}
|
|
2396
|
+
function deriveProjectName(targetRepoRoot) {
|
|
2397
|
+
const remote = readRemote(targetRepoRoot);
|
|
2398
|
+
const fromRemote = remote ? repositoryNameFromRemote(remote) : void 0;
|
|
2399
|
+
const candidate = fromRemote ?? basename(resolve(targetRepoRoot));
|
|
2400
|
+
const name = candidate.trim();
|
|
2401
|
+
if (EMPTY_SEGMENTS.has(name))
|
|
2402
|
+
return void 0;
|
|
2403
|
+
return name.slice(0, MAX_DERIVED_NAME_LEN);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// dist/own-engagement.js
|
|
2407
|
+
async function createOwnedEngagement(serviceUrl, targetRepo, deps = {}) {
|
|
2408
|
+
let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "create", targetRepo }, deps);
|
|
2409
|
+
for (; ; ) {
|
|
2410
|
+
try {
|
|
2411
|
+
return {
|
|
2412
|
+
...await createEngagement(serviceUrl, void 0, credential.credential, deriveProjectName(targetRepo)),
|
|
2413
|
+
actingAccountId: credential.accountId
|
|
2414
|
+
};
|
|
2415
|
+
} catch (err) {
|
|
2416
|
+
if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
|
|
2417
|
+
throw err;
|
|
2418
|
+
const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
|
|
2419
|
+
if (replacement === null) {
|
|
2420
|
+
throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
|
|
2421
|
+
}
|
|
2422
|
+
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
2423
|
+
`);
|
|
2424
|
+
credential = replacement;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
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.";
|
|
2429
|
+
async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps = {}) {
|
|
2430
|
+
let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "join", targetRepo, engagementId }, deps);
|
|
2431
|
+
for (; ; ) {
|
|
2432
|
+
try {
|
|
2433
|
+
return {
|
|
2434
|
+
...await joinEngagement(serviceUrl, engagementId, credential.credential),
|
|
2435
|
+
actingAccountId: credential.accountId
|
|
2436
|
+
};
|
|
2437
|
+
} catch (err) {
|
|
2438
|
+
if (!(err instanceof CreateEngagementRefused))
|
|
2439
|
+
throw err;
|
|
2440
|
+
if (err.status === 403) {
|
|
2441
|
+
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
|
|
2442
|
+
}
|
|
2443
|
+
if (err.status !== 401) {
|
|
2444
|
+
throw err;
|
|
2445
|
+
}
|
|
2446
|
+
const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
|
|
2447
|
+
if (replacement === null) {
|
|
2448
|
+
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
|
|
2449
|
+
}
|
|
2450
|
+
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
2451
|
+
`);
|
|
2452
|
+
credential = replacement;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2183
2457
|
// dist/mint-board-code.js
|
|
2184
2458
|
async function mintBoardEnterCode(baseUrl, sessionToken) {
|
|
2185
2459
|
const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
|
|
@@ -2512,14 +2786,14 @@ function renderBuildRecordMarkdown(record2) {
|
|
|
2512
2786
|
|
|
2513
2787
|
// dist/build-record/write.js
|
|
2514
2788
|
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2515
|
-
import { join as join7, resolve, relative as relative2, isAbsolute } from "node:path";
|
|
2789
|
+
import { join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
|
|
2516
2790
|
var BUILD_RECORD_DIR = join7("docs", "build-records");
|
|
2517
2791
|
function serialiseRecordJson(record2) {
|
|
2518
2792
|
return JSON.stringify(record2, null, 2) + "\n";
|
|
2519
2793
|
}
|
|
2520
2794
|
function writeBuildRecord(repoRoot, record2) {
|
|
2521
|
-
const outDir =
|
|
2522
|
-
const expected =
|
|
2795
|
+
const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
|
|
2796
|
+
const expected = resolve2(repoRoot, BUILD_RECORD_DIR);
|
|
2523
2797
|
const rel = relative2(expected, outDir);
|
|
2524
2798
|
if (rel !== "" || isAbsolute(rel)) {
|
|
2525
2799
|
throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
|
|
@@ -2792,8 +3066,351 @@ var PLACEHOLDERS = {
|
|
|
2792
3066
|
"--evidence": '"\u2026"'
|
|
2793
3067
|
};
|
|
2794
3068
|
|
|
3069
|
+
// dist/banner.js
|
|
3070
|
+
var MARK_FULL = {
|
|
3071
|
+
width: 32,
|
|
3072
|
+
discRows: 7,
|
|
3073
|
+
lines: [
|
|
3074
|
+
" :+oshhhhso+:",
|
|
3075
|
+
" :sdMMMMMMMMMMMMds:",
|
|
3076
|
+
" :hMMMMMMMMMMMMMMMMMMh:",
|
|
3077
|
+
" oMMMMMMMMMMMMMMMMMMMMMMo",
|
|
3078
|
+
" oMMMMMMMMMMMMMMMMMMMMMMMMo",
|
|
3079
|
+
" NMMMMMMMMMMMMMMMMMMMMMMMMN",
|
|
3080
|
+
" :ssssssssssssssssssssssssss:",
|
|
3081
|
+
" +dddddddddddddddddddddddddd+",
|
|
3082
|
+
" Nd dN",
|
|
3083
|
+
" oMy yMo",
|
|
3084
|
+
" oMh: :hMo",
|
|
3085
|
+
" :hMy- -yMh:",
|
|
3086
|
+
" :sdNys++--++syNds:",
|
|
3087
|
+
" :+oshhhhso+:"
|
|
3088
|
+
]
|
|
3089
|
+
};
|
|
3090
|
+
var MARK_NARROW = {
|
|
3091
|
+
width: 24,
|
|
3092
|
+
discRows: 5,
|
|
3093
|
+
lines: [
|
|
3094
|
+
" -ohdMMMMdho-",
|
|
3095
|
+
" +dMMMMMMMMMMMMd+",
|
|
3096
|
+
" yMMMMMMMMMMMMMMMMy",
|
|
3097
|
+
" sMMMMMMMMMMMMMMMMMMs",
|
|
3098
|
+
" yhhhhhhhhhhhhhhhhhhy",
|
|
3099
|
+
" NdhhhhhhhhhhhhhhhhdN",
|
|
3100
|
+
" sd ds",
|
|
3101
|
+
" yd: :dy",
|
|
3102
|
+
" +dy+ +yd+",
|
|
3103
|
+
" -ohhhhhhhho-"
|
|
3104
|
+
]
|
|
3105
|
+
};
|
|
3106
|
+
var UNICODE_GLYPHS = {
|
|
3107
|
+
rule: "\u2500",
|
|
3108
|
+
swatch: "\u2584",
|
|
3109
|
+
dot: "\xB7",
|
|
3110
|
+
dash: "\u2014"
|
|
3111
|
+
};
|
|
3112
|
+
var ASCII_GLYPHS = {
|
|
3113
|
+
rule: "-",
|
|
3114
|
+
swatch: "#",
|
|
3115
|
+
dot: "|",
|
|
3116
|
+
dash: "-"
|
|
3117
|
+
};
|
|
3118
|
+
function toAscii(text) {
|
|
3119
|
+
let out = text;
|
|
3120
|
+
for (const key of Object.keys(UNICODE_GLYPHS)) {
|
|
3121
|
+
out = out.split(UNICODE_GLYPHS[key]).join(ASCII_GLYPHS[key]);
|
|
3122
|
+
}
|
|
3123
|
+
return out.replace(/[^ -~]/g, "?");
|
|
3124
|
+
}
|
|
3125
|
+
var INK = {
|
|
3126
|
+
plain: "",
|
|
3127
|
+
dim: "2",
|
|
3128
|
+
bold: "1",
|
|
3129
|
+
/** Yellow — the nearest ANSI seat for the brand's gold, used for labels and the URL. */
|
|
3130
|
+
key: "33",
|
|
3131
|
+
keyBold: "33;1"
|
|
3132
|
+
};
|
|
3133
|
+
var SWATCH_SGR = ["90", "31", "32", "33", "34", "35", "36", "37"];
|
|
3134
|
+
var ESC = "\x1B";
|
|
3135
|
+
var RESET = `${ESC}[0m`;
|
|
3136
|
+
function paint(segments, colour) {
|
|
3137
|
+
return segments.map((s) => colour && s.sgr !== "" ? `${ESC}[${s.sgr}m${s.text}${RESET}` : s.text).join("");
|
|
3138
|
+
}
|
|
3139
|
+
var GAP = 4;
|
|
3140
|
+
var FULL_LAYOUT = { art: MARK_FULL, label: 13, rule: 28, palette: true, minColumns: 76 };
|
|
3141
|
+
var NARROW_LAYOUT = { art: MARK_NARROW, label: 10, rule: 20, palette: false, minColumns: 48 };
|
|
3142
|
+
var DEFAULT_COLUMNS = 80;
|
|
3143
|
+
function usableColumns(environment) {
|
|
3144
|
+
const columns = environment.columns;
|
|
3145
|
+
return columns !== void 0 && columns > 0 ? columns : DEFAULT_COLUMNS;
|
|
3146
|
+
}
|
|
3147
|
+
function isSet(value) {
|
|
3148
|
+
return value !== void 0 && value !== "";
|
|
3149
|
+
}
|
|
3150
|
+
function plainTextRequested(env) {
|
|
3151
|
+
return isSet(env["NO_COLOR"]) || env["TERM"] === "dumb";
|
|
3152
|
+
}
|
|
3153
|
+
function decideCut(environment = {}) {
|
|
3154
|
+
const env = environment.env ?? {};
|
|
3155
|
+
if (environment.quiet === true)
|
|
3156
|
+
return "none";
|
|
3157
|
+
if (environment.isTTY !== true)
|
|
3158
|
+
return "line";
|
|
3159
|
+
if (plainTextRequested(env))
|
|
3160
|
+
return "line";
|
|
3161
|
+
const columns = usableColumns(environment);
|
|
3162
|
+
if (columns >= FULL_LAYOUT.minColumns)
|
|
3163
|
+
return "full";
|
|
3164
|
+
if (columns >= NARROW_LAYOUT.minColumns)
|
|
3165
|
+
return "narrow";
|
|
3166
|
+
return "line";
|
|
3167
|
+
}
|
|
3168
|
+
function decideColour(environment = {}) {
|
|
3169
|
+
const env = environment.env ?? {};
|
|
3170
|
+
const force = env["FORCE_COLOR"];
|
|
3171
|
+
if (isSet(force))
|
|
3172
|
+
return force !== "0";
|
|
3173
|
+
if (plainTextRequested(env))
|
|
3174
|
+
return false;
|
|
3175
|
+
return environment.isTTY === true;
|
|
3176
|
+
}
|
|
3177
|
+
function decideUnicode(environment = {}) {
|
|
3178
|
+
const env = environment.env ?? {};
|
|
3179
|
+
if ((environment.platform ?? "") !== "win32") {
|
|
3180
|
+
const locale = env["LC_ALL"] ?? env["LC_CTYPE"] ?? env["LANG"] ?? "";
|
|
3181
|
+
return locale === "" || /utf-?8/i.test(locale);
|
|
3182
|
+
}
|
|
3183
|
+
return isSet(env["WT_SESSION"]) || isSet(env["TERMINUS_SUBLIME"]) || env["ConEmuTask"] === "{cmd::Cmder}" || env["TERM_PROGRAM"] === "vscode";
|
|
3184
|
+
}
|
|
3185
|
+
function padEnd(text, width) {
|
|
3186
|
+
return text + " ".repeat(Math.max(0, width - text.length));
|
|
3187
|
+
}
|
|
3188
|
+
function clip(text, width) {
|
|
3189
|
+
if (width < 8 || text.length <= width)
|
|
3190
|
+
return text;
|
|
3191
|
+
return `${text.slice(0, width - 3)}...`;
|
|
3192
|
+
}
|
|
3193
|
+
function paletteRow(swatch) {
|
|
3194
|
+
const row = [];
|
|
3195
|
+
SWATCH_SGR.forEach((sgr, i) => {
|
|
3196
|
+
if (i > 0)
|
|
3197
|
+
row.push({ text: " ", sgr: INK.plain });
|
|
3198
|
+
row.push({ text: swatch.repeat(2), sgr });
|
|
3199
|
+
});
|
|
3200
|
+
return row;
|
|
3201
|
+
}
|
|
3202
|
+
function factColumn(content, layout, glyphs, text, valueWidth) {
|
|
3203
|
+
const rows = [];
|
|
3204
|
+
const head = text(content.head);
|
|
3205
|
+
const at = head.indexOf("@");
|
|
3206
|
+
rows.push(at < 0 ? [{ text: head, sgr: INK.keyBold }] : [
|
|
3207
|
+
{ text: head.slice(0, at), sgr: INK.keyBold },
|
|
3208
|
+
{ text: "@", sgr: INK.dim },
|
|
3209
|
+
{ text: head.slice(at + 1), sgr: INK.bold }
|
|
3210
|
+
]);
|
|
3211
|
+
rows.push([{ text: glyphs.rule.repeat(layout.rule), sgr: INK.dim }]);
|
|
3212
|
+
const narrow = layout.art === MARK_NARROW;
|
|
3213
|
+
for (const fact2 of content.facts) {
|
|
3214
|
+
const raw = narrow ? fact2.narrow : fact2.value;
|
|
3215
|
+
if (raw === void 0)
|
|
3216
|
+
continue;
|
|
3217
|
+
rows.push([
|
|
3218
|
+
{ text: padEnd(`${text(fact2.label)}:`, layout.label), sgr: INK.key },
|
|
3219
|
+
{ text: clip(text(raw), valueWidth), sgr: INK.plain }
|
|
3220
|
+
]);
|
|
3221
|
+
}
|
|
3222
|
+
if (layout.palette) {
|
|
3223
|
+
rows.push([]);
|
|
3224
|
+
rows.push(paletteRow(glyphs.swatch));
|
|
3225
|
+
}
|
|
3226
|
+
return rows;
|
|
3227
|
+
}
|
|
3228
|
+
function renderBlock(content, layout, environment) {
|
|
3229
|
+
const unicode = decideUnicode(environment);
|
|
3230
|
+
const colour = decideColour(environment);
|
|
3231
|
+
const glyphs = unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
|
|
3232
|
+
const text = (raw) => unicode ? raw : toAscii(raw);
|
|
3233
|
+
const art = layout.art;
|
|
3234
|
+
const valueWidth = usableColumns(environment) - (art.width + GAP + layout.label);
|
|
3235
|
+
const right = factColumn(content, layout, glyphs, text, valueWidth);
|
|
3236
|
+
const top = Math.max(0, Math.floor((art.lines.length - right.length) / 2));
|
|
3237
|
+
const height = Math.max(art.lines.length, right.length + top);
|
|
3238
|
+
const lines = [];
|
|
3239
|
+
for (let i = 0; i < height; i++) {
|
|
3240
|
+
const artLine = art.lines[i] ?? "";
|
|
3241
|
+
const segments = [];
|
|
3242
|
+
if (artLine.length > 0) {
|
|
3243
|
+
segments.push({ text: artLine, sgr: i < art.discRows ? INK.plain : INK.dim });
|
|
3244
|
+
}
|
|
3245
|
+
const rightRow = right[i - top];
|
|
3246
|
+
if (rightRow !== void 0 && rightRow.length > 0) {
|
|
3247
|
+
segments.push({ text: " ".repeat(Math.max(0, art.width + GAP - artLine.length)), sgr: INK.plain });
|
|
3248
|
+
segments.push(...rightRow);
|
|
3249
|
+
}
|
|
3250
|
+
lines.push(segments);
|
|
3251
|
+
}
|
|
3252
|
+
const tagline = layout.art === MARK_NARROW ? content.tagNarrow ?? content.tag : content.tag;
|
|
3253
|
+
lines.push([]);
|
|
3254
|
+
tagline.forEach((line, i) => {
|
|
3255
|
+
lines.push([{ text: ` ${text(line)}`, sgr: i === 0 ? INK.bold : INK.dim }]);
|
|
3256
|
+
});
|
|
3257
|
+
lines.push([]);
|
|
3258
|
+
lines.push([{ text: ` ${text(content.url)}`, sgr: INK.key }]);
|
|
3259
|
+
return lines.map((segments) => paint(segments, colour)).join("\n");
|
|
3260
|
+
}
|
|
3261
|
+
function renderLine(content, environment) {
|
|
3262
|
+
const unicode = decideUnicode(environment);
|
|
3263
|
+
const dot = unicode ? UNICODE_GLYPHS.dot : ASCII_GLYPHS.dot;
|
|
3264
|
+
const named = content.version === "" ? "Halfcycle" : `Halfcycle v${content.version}`;
|
|
3265
|
+
const raw = `${named} ${dot} ${content.url}`;
|
|
3266
|
+
return unicode ? raw : toAscii(raw);
|
|
3267
|
+
}
|
|
3268
|
+
function renderBanner(content, environment = {}) {
|
|
3269
|
+
switch (decideCut(environment)) {
|
|
3270
|
+
case "none":
|
|
3271
|
+
return "";
|
|
3272
|
+
case "line":
|
|
3273
|
+
return renderLine(content, environment);
|
|
3274
|
+
case "narrow":
|
|
3275
|
+
return renderBlock(content, NARROW_LAYOUT, environment);
|
|
3276
|
+
case "full":
|
|
3277
|
+
return renderBlock(content, FULL_LAYOUT, environment);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
function printBanner(sink, content, environment = {}) {
|
|
3281
|
+
const text = renderBanner(content, environment);
|
|
3282
|
+
if (text === "")
|
|
3283
|
+
return;
|
|
3284
|
+
sink.write(`${text}
|
|
3285
|
+
`);
|
|
3286
|
+
}
|
|
3287
|
+
function currentEnvironment(quiet2) {
|
|
3288
|
+
return {
|
|
3289
|
+
columns: process.stdout.columns,
|
|
3290
|
+
isTTY: process.stdout.isTTY === true,
|
|
3291
|
+
quiet: quiet2,
|
|
3292
|
+
env: process.env,
|
|
3293
|
+
platform: process.platform
|
|
3294
|
+
};
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
// dist/banner-facts.js
|
|
3298
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
3299
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
3300
|
+
import { basename as basename2, join as join9, resolve as resolve3 } from "node:path";
|
|
3301
|
+
var BRAND_URL = "halfcycle.ai";
|
|
3302
|
+
var TAGLINE = [
|
|
3303
|
+
"Fewer cycles.",
|
|
3304
|
+
"The thinking moves earlier. So does the finish."
|
|
3305
|
+
];
|
|
3306
|
+
var TAGLINE_NARROW = [
|
|
3307
|
+
"Fewer cycles.",
|
|
3308
|
+
"The thinking moves earlier.",
|
|
3309
|
+
"So does the finish."
|
|
3310
|
+
];
|
|
3311
|
+
function bundleVersion() {
|
|
3312
|
+
try {
|
|
3313
|
+
const pkg = JSON.parse(readFileSync8(join9(BUNDLE_ROOT, "package.json"), "utf-8"));
|
|
3314
|
+
return typeof pkg.version === "string" ? pkg.version : void 0;
|
|
3315
|
+
} catch {
|
|
3316
|
+
return void 0;
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
function claudeCodeVersion() {
|
|
3320
|
+
try {
|
|
3321
|
+
const raw = execFileSync3("claude", ["--version"], {
|
|
3322
|
+
encoding: "utf-8",
|
|
3323
|
+
timeout: 3e3,
|
|
3324
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
3325
|
+
});
|
|
3326
|
+
return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
|
|
3327
|
+
} catch {
|
|
3328
|
+
return void 0;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
var REAL_PROBES = {
|
|
3332
|
+
bundleVersion,
|
|
3333
|
+
claudeCodeVersion,
|
|
3334
|
+
projectName: (dir) => {
|
|
3335
|
+
try {
|
|
3336
|
+
return deriveProjectName(dir);
|
|
3337
|
+
} catch {
|
|
3338
|
+
return void 0;
|
|
3339
|
+
}
|
|
3340
|
+
},
|
|
3341
|
+
commitCount: (dir) => {
|
|
3342
|
+
try {
|
|
3343
|
+
return readCommitCount(dir);
|
|
3344
|
+
} catch {
|
|
3345
|
+
return null;
|
|
3346
|
+
}
|
|
3347
|
+
},
|
|
3348
|
+
installedVersion: (dir) => {
|
|
3349
|
+
try {
|
|
3350
|
+
return readBundlePin(dir)?.version;
|
|
3351
|
+
} catch {
|
|
3352
|
+
return void 0;
|
|
3353
|
+
}
|
|
3354
|
+
},
|
|
3355
|
+
planeHost: (env) => {
|
|
3356
|
+
try {
|
|
3357
|
+
const resolved = resolveControlOrigin(env);
|
|
3358
|
+
return resolved.source === "default" ? BRAND_URL : new URL(resolved.origin).host;
|
|
3359
|
+
} catch {
|
|
3360
|
+
return void 0;
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
};
|
|
3364
|
+
function fact(label, value, narrow) {
|
|
3365
|
+
if (value === void 0 || value === "")
|
|
3366
|
+
return void 0;
|
|
3367
|
+
return narrow === void 0 ? { label, value } : { label, value, narrow };
|
|
3368
|
+
}
|
|
3369
|
+
function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBES) {
|
|
3370
|
+
const dot = UNICODE_GLYPHS.dot;
|
|
3371
|
+
const dash = UNICODE_GLYPHS.dash;
|
|
3372
|
+
const version = probes.bundleVersion();
|
|
3373
|
+
const name = probes.projectName(projectDir) ?? safeBasename(projectDir);
|
|
3374
|
+
const commits = probes.commitCount(projectDir);
|
|
3375
|
+
const installed = probes.installedVersion(projectDir);
|
|
3376
|
+
const project = commits === null ? name : `${name} ${dot} git ${dot} ${commits} ${commits === 1 ? "commit" : "commits"}`;
|
|
3377
|
+
const status = installed === void 0 ? `new ${dash} nothing installed yet` : `installed ${dash} v${installed}`;
|
|
3378
|
+
const facts = [
|
|
3379
|
+
fact("Version", version, version),
|
|
3380
|
+
fact("Claude Code", probes.claudeCodeVersion()),
|
|
3381
|
+
fact("Project", project, name),
|
|
3382
|
+
fact("Status", status, installed === void 0 ? "new" : "installed"),
|
|
3383
|
+
fact("Plane", probes.planeHost(env))
|
|
3384
|
+
].filter((f) => f !== void 0);
|
|
3385
|
+
return {
|
|
3386
|
+
head: `halfcycle@${name}`,
|
|
3387
|
+
facts,
|
|
3388
|
+
tag: TAGLINE,
|
|
3389
|
+
tagNarrow: TAGLINE_NARROW,
|
|
3390
|
+
url: BRAND_URL,
|
|
3391
|
+
// `''` when even this package's own manifest could not be read. The one-line cut
|
|
3392
|
+
// drops the whole `vX.Y.Z` clause in that case rather than printing a bare `v`.
|
|
3393
|
+
version: version ?? ""
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3396
|
+
function safeBasename(dir) {
|
|
3397
|
+
try {
|
|
3398
|
+
const name = basename2(resolve3(dir)).trim();
|
|
3399
|
+
return name === "" ? "project" : name;
|
|
3400
|
+
} catch {
|
|
3401
|
+
return "project";
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
|
|
2795
3405
|
// dist/bin.js
|
|
2796
|
-
var
|
|
3406
|
+
var ASSUME_YES_FLAGS = /* @__PURE__ */ new Set(["--yes", "-y"]);
|
|
3407
|
+
var QUIET_FLAGS = /* @__PURE__ */ new Set(["--quiet", "-q"]);
|
|
3408
|
+
var VERBOSE_FLAGS = /* @__PURE__ */ new Set(["--verbose", "-v"]);
|
|
3409
|
+
var rawArgs = process.argv.slice(2);
|
|
3410
|
+
var assumeYes = rawArgs.some((a) => ASSUME_YES_FLAGS.has(a));
|
|
3411
|
+
var quiet = rawArgs.some((a) => QUIET_FLAGS.has(a));
|
|
3412
|
+
var verbose = rawArgs.some((a) => VERBOSE_FLAGS.has(a));
|
|
3413
|
+
var args = rawArgs.filter((a) => !ASSUME_YES_FLAGS.has(a) && !QUIET_FLAGS.has(a) && !VERBOSE_FLAGS.has(a));
|
|
2797
3414
|
var MIN_HEADERS_HELPER_VERSION = "2.1.118";
|
|
2798
3415
|
var MIN_HEADER_ROTATION_VERSION = "2.1.193";
|
|
2799
3416
|
function compareVersions(a, b) {
|
|
@@ -2807,10 +3424,10 @@ function compareVersions(a, b) {
|
|
|
2807
3424
|
}
|
|
2808
3425
|
return 0;
|
|
2809
3426
|
}
|
|
2810
|
-
function reportClaudeCodeVersion() {
|
|
3427
|
+
function reportClaudeCodeVersion(verbose2) {
|
|
2811
3428
|
let raw;
|
|
2812
3429
|
try {
|
|
2813
|
-
raw =
|
|
3430
|
+
raw = execFileSync4("claude", ["--version"], {
|
|
2814
3431
|
encoding: "utf-8",
|
|
2815
3432
|
timeout: 5e3,
|
|
2816
3433
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -2823,73 +3440,44 @@ function reportClaudeCodeVersion() {
|
|
|
2823
3440
|
return;
|
|
2824
3441
|
const version = found[1];
|
|
2825
3442
|
if (compareVersions(version, MIN_HEADERS_HELPER_VERSION) >= 0) {
|
|
2826
|
-
|
|
3443
|
+
if (verbose2) {
|
|
3444
|
+
process.stdout.write(`[halfcycle] Claude Code ${version} detected \u2014 new enough for the credential helper.
|
|
2827
3445
|
`);
|
|
3446
|
+
}
|
|
2828
3447
|
return;
|
|
2829
3448
|
}
|
|
2830
3449
|
process.stdout.write(`[halfcycle] WARNING: Claude Code ${version} is older than ${MIN_HEADERS_HELPER_VERSION}, which ignores the credential helper this install wrote. The Halfcycle server will connect with no credential and every call will fail \u2014 upgrade Claude Code, then re-open this folder.
|
|
2831
3450
|
`);
|
|
2832
3451
|
}
|
|
3452
|
+
function shortId(id) {
|
|
3453
|
+
return id.length > 10 ? `${id.slice(0, 8)}\u2026` : id;
|
|
3454
|
+
}
|
|
2833
3455
|
var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|client]
|
|
2834
3456
|
install into target (default: the current directory)
|
|
3457
|
+
--yes / -y: use this machine's saved sign-in without being asked
|
|
2835
3458
|
halfcycle check-drift <target-repo>
|
|
2836
3459
|
halfcycle build-record <phase-id> [--repo <root>]
|
|
2837
3460
|
halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
|
|
2838
3461
|
halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
|
|
3462
|
+
|
|
3463
|
+
--quiet / -q: print no opening banner (any command)
|
|
3464
|
+
--verbose / -v: print full run detail (paths written, merged, skipped) on install
|
|
2839
3465
|
`;
|
|
2840
3466
|
function isHalfcycleMonorepo(dir) {
|
|
2841
3467
|
try {
|
|
2842
|
-
const pkg = JSON.parse(
|
|
3468
|
+
const pkg = JSON.parse(readFileSync9(join10(dir, "package.json"), "utf-8"));
|
|
2843
3469
|
return pkg.name === "halfcycle-monorepo";
|
|
2844
3470
|
} catch {
|
|
2845
3471
|
return false;
|
|
2846
3472
|
}
|
|
2847
3473
|
}
|
|
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
3474
|
async function main() {
|
|
2892
3475
|
const [cmd, ...rest] = args;
|
|
3476
|
+
const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase)$/.test(cmd);
|
|
3477
|
+
const installArm = cmd === "install" || cmd === void 0 || bareTarget;
|
|
3478
|
+
const positionals = cmd === "install" ? rest : args;
|
|
3479
|
+
const targetRepo = installArm ? positionals[0] ?? process.cwd() : process.cwd();
|
|
3480
|
+
printBanner(process.stdout, openingBannerContent(targetRepo), currentEnvironment(quiet));
|
|
2893
3481
|
if (cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
2894
3482
|
process.stdout.write(`halfcycle \u2014 install and drive the Halfcycle method in a repository.
|
|
2895
3483
|
|
|
@@ -2898,10 +3486,7 @@ ${USAGE}`);
|
|
|
2898
3486
|
process.exit(0);
|
|
2899
3487
|
return;
|
|
2900
3488
|
}
|
|
2901
|
-
|
|
2902
|
-
if (cmd === "install" || cmd === void 0 || bareTarget) {
|
|
2903
|
-
const positionals = cmd === "install" ? rest : args;
|
|
2904
|
-
const targetRepo = positionals[0] ?? process.cwd();
|
|
3489
|
+
if (installArm) {
|
|
2905
3490
|
const engagementIdArg = positionals[1];
|
|
2906
3491
|
const engagementTypeRaw = positionals[2] ?? "client";
|
|
2907
3492
|
if (isHalfcycleMonorepo(targetRepo)) {
|
|
@@ -2923,18 +3508,26 @@ ${USAGE}`);
|
|
|
2923
3508
|
try {
|
|
2924
3509
|
const controlOrigin = resolveControlOrigin(process.env);
|
|
2925
3510
|
const serviceUrl = controlOrigin.origin;
|
|
2926
|
-
|
|
3511
|
+
if (verbose || controlOrigin.source !== "default") {
|
|
3512
|
+
process.stdout.write(`[halfcycle] Halfcycle plane: ${controlOriginNote(controlOrigin)}
|
|
2927
3513
|
`);
|
|
3514
|
+
}
|
|
2928
3515
|
const pinned = readPinnedEngagement(targetRepo);
|
|
2929
3516
|
const requestedId = engagementIdArg ?? pinned?.engagementId;
|
|
2930
3517
|
const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
|
|
2931
3518
|
let engagementId;
|
|
2932
3519
|
let credential;
|
|
3520
|
+
let actingAccountId;
|
|
3521
|
+
let foundLine = "";
|
|
2933
3522
|
if (reusable !== void 0 && pinned !== null) {
|
|
2934
3523
|
engagementId = pinned.engagementId;
|
|
2935
3524
|
credential = reusable;
|
|
2936
|
-
|
|
3525
|
+
actingAccountId = readBundlePin(targetRepo)?.accountId;
|
|
3526
|
+
foundLine = `already set up here \u2014 reusing engagement ${shortId(pinned.engagementId)}`;
|
|
3527
|
+
if (verbose) {
|
|
3528
|
+
process.stdout.write(`[halfcycle] Re-using the engagement already pinned in this repo: ${pinned.engagementId}
|
|
2937
3529
|
`);
|
|
3530
|
+
}
|
|
2938
3531
|
if (pinned.fromLegacyEnvLocal) {
|
|
2939
3532
|
process.stdout.write(`[halfcycle] Its credential is in this repository's .env.local \u2014 an install from before
|
|
2940
3533
|
[halfcycle] credentials moved out of the tree. It is being copied to
|
|
@@ -2943,8 +3536,9 @@ ${USAGE}`);
|
|
|
2943
3536
|
`);
|
|
2944
3537
|
}
|
|
2945
3538
|
} else if (requestedId !== void 0) {
|
|
2946
|
-
const joined = await joinPinnedEngagement(serviceUrl, requestedId);
|
|
3539
|
+
const joined = await joinPinnedEngagement(serviceUrl, requestedId, targetRepo, { assumeYes });
|
|
2947
3540
|
engagementId = joined.engagementId;
|
|
3541
|
+
actingAccountId = joined.actingAccountId;
|
|
2948
3542
|
credential = {
|
|
2949
3543
|
serviceUrl,
|
|
2950
3544
|
token: joined.sessionToken,
|
|
@@ -2955,11 +3549,15 @@ ${USAGE}`);
|
|
|
2955
3549
|
// an absent value, never `undefined` verbatim.
|
|
2956
3550
|
controlTelemetryUrl: joined.controlTelemetryUrl
|
|
2957
3551
|
};
|
|
2958
|
-
|
|
3552
|
+
foundLine = `this repo is pinned to a shared project \u2014 joining it as you (${shortId(joined.engagementId)})`;
|
|
3553
|
+
if (verbose) {
|
|
3554
|
+
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
3555
|
`);
|
|
3556
|
+
}
|
|
2960
3557
|
} else {
|
|
2961
|
-
const created = await createOwnedEngagement(serviceUrl);
|
|
3558
|
+
const created = await createOwnedEngagement(serviceUrl, targetRepo, { assumeYes });
|
|
2962
3559
|
engagementId = created.engagementId;
|
|
3560
|
+
actingAccountId = created.actingAccountId;
|
|
2963
3561
|
credential = {
|
|
2964
3562
|
serviceUrl,
|
|
2965
3563
|
token: created.sessionToken,
|
|
@@ -2973,26 +3571,32 @@ ${USAGE}`);
|
|
|
2973
3571
|
// emission is fail-open on that absence.
|
|
2974
3572
|
controlTelemetryUrl: created.controlTelemetryUrl
|
|
2975
3573
|
};
|
|
2976
|
-
|
|
3574
|
+
foundLine = `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
|
|
3575
|
+
if (verbose) {
|
|
3576
|
+
process.stdout.write(`[halfcycle] Created engagement ${created.engagementId}
|
|
2977
3577
|
`);
|
|
3578
|
+
}
|
|
2978
3579
|
}
|
|
2979
3580
|
const result = await install({
|
|
2980
3581
|
targetRepo,
|
|
2981
3582
|
engagementId,
|
|
2982
3583
|
engagementType: engagementTypeRaw,
|
|
2983
|
-
credential
|
|
3584
|
+
credential,
|
|
3585
|
+
accountId: actingAccountId
|
|
2984
3586
|
});
|
|
2985
|
-
|
|
3587
|
+
if (verbose) {
|
|
3588
|
+
process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
|
|
2986
3589
|
`);
|
|
2987
|
-
|
|
3590
|
+
process.stdout.write(`[halfcycle] Written: ${result.writtenPaths.length} paths
|
|
2988
3591
|
`);
|
|
2989
|
-
|
|
2990
|
-
|
|
3592
|
+
if (result.mergedPaths.length > 0) {
|
|
3593
|
+
process.stdout.write(`[halfcycle] Merged: ${result.mergedPaths.join(", ")}
|
|
2991
3594
|
`);
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
3595
|
+
}
|
|
3596
|
+
if (result.skippedPaths.length > 0) {
|
|
3597
|
+
process.stdout.write(`[halfcycle] Skipped (already present): ${result.skippedPaths.join(", ")}
|
|
2995
3598
|
`);
|
|
3599
|
+
}
|
|
2996
3600
|
}
|
|
2997
3601
|
if (result.collidedPaths.length > 0) {
|
|
2998
3602
|
process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${result.collidedPaths.join(", ")}
|
|
@@ -3010,22 +3614,37 @@ ${USAGE}`);
|
|
|
3010
3614
|
}
|
|
3011
3615
|
const probe = await probeMcpOrigin(credential.mcpUrl);
|
|
3012
3616
|
if (probe.reached) {
|
|
3013
|
-
|
|
3617
|
+
if (verbose) {
|
|
3618
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
|
|
3014
3619
|
`);
|
|
3620
|
+
}
|
|
3015
3621
|
} else {
|
|
3016
3622
|
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
|
|
3017
3623
|
[halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not the CONTROL origin (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
|
|
3018
3624
|
`);
|
|
3019
3625
|
}
|
|
3020
|
-
|
|
3626
|
+
if (verbose) {
|
|
3627
|
+
process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_COVERAGE_REQUIRED_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
|
|
3628
|
+
`);
|
|
3629
|
+
process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through .halfcycle/mcp-headers.sh, so there is no token in this tree to commit and nothing for you to export.
|
|
3630
|
+
`);
|
|
3631
|
+
process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the helper is a shell command, and Claude Code will not run it until you accept. Needs Claude Code ${MIN_HEADERS_HELPER_VERSION} or newer (${MIN_HEADER_ROTATION_VERSION}+ to re-authenticate a rotated token without restarting).
|
|
3632
|
+
`);
|
|
3633
|
+
process.stdout.write(`[halfcycle] If Halfcycle tools appear but every call returns 401, the helper could not read HALFCYCLE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
|
|
3634
|
+
`);
|
|
3635
|
+
}
|
|
3636
|
+
reportClaudeCodeVersion(verbose);
|
|
3637
|
+
const identity = await describeAccount(credential.serviceUrl, credential.token);
|
|
3638
|
+
const written = result.writtenPaths.length;
|
|
3639
|
+
const merged = result.mergedPaths.length;
|
|
3640
|
+
process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId)}
|
|
3021
3641
|
`);
|
|
3022
|
-
process.stdout.write(`[halfcycle]
|
|
3642
|
+
process.stdout.write(`[halfcycle] Found: ${foundLine}
|
|
3023
3643
|
`);
|
|
3024
|
-
process.stdout.write(`[halfcycle]
|
|
3644
|
+
process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, guards armed on every edit
|
|
3025
3645
|
`);
|
|
3026
|
-
process.stdout.write(`[halfcycle]
|
|
3646
|
+
process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
|
|
3027
3647
|
`);
|
|
3028
|
-
reportClaudeCodeVersion();
|
|
3029
3648
|
try {
|
|
3030
3649
|
const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
|
|
3031
3650
|
process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
|
|
@@ -3050,17 +3669,17 @@ ${USAGE}`);
|
|
|
3050
3669
|
return;
|
|
3051
3670
|
}
|
|
3052
3671
|
if (cmd === "check-drift") {
|
|
3053
|
-
const
|
|
3054
|
-
if (!
|
|
3672
|
+
const targetRepo2 = rest[0];
|
|
3673
|
+
if (!targetRepo2) {
|
|
3055
3674
|
process.stderr.write("halfcycle-bundle: usage: halfcycle-bundle check-drift <target-repo>\n");
|
|
3056
3675
|
process.exit(1);
|
|
3057
3676
|
return;
|
|
3058
3677
|
}
|
|
3059
3678
|
try {
|
|
3060
|
-
const { drifted, installed, current } = checkDrift(
|
|
3679
|
+
const { drifted, installed, current } = checkDrift(targetRepo2);
|
|
3061
3680
|
if (drifted) {
|
|
3062
3681
|
process.stdout.write(`[halfcycle-bundle] DRIFT DETECTED: installed=${installed ?? "none"} current=${current}
|
|
3063
|
-
[halfcycle-bundle] Re-run "halfcycle-bundle install ${
|
|
3682
|
+
[halfcycle-bundle] Re-run "halfcycle-bundle install ${targetRepo2}" to sync.
|
|
3064
3683
|
`);
|
|
3065
3684
|
process.exit(2);
|
|
3066
3685
|
} else {
|
|
@@ -3085,12 +3704,12 @@ ${USAGE}`);
|
|
|
3085
3704
|
}
|
|
3086
3705
|
const phaseId = Number(phaseArg);
|
|
3087
3706
|
try {
|
|
3088
|
-
const inputPath =
|
|
3089
|
-
const input = JSON.parse(
|
|
3707
|
+
const inputPath = join10(repoRoot, ".workbench", "build-record", `phase-${phaseId}.input.json`);
|
|
3708
|
+
const input = JSON.parse(readFileSync9(inputPath, "utf-8"));
|
|
3090
3709
|
const result = assemblePhaseBuildRecord({
|
|
3091
3710
|
repoRoot,
|
|
3092
|
-
phasesDir:
|
|
3093
|
-
guardEvalLogDir: input.guardEvalLogDir ??
|
|
3711
|
+
phasesDir: join10(repoRoot, "docs", "phases"),
|
|
3712
|
+
guardEvalLogDir: input.guardEvalLogDir ?? join10(repoRoot, ".workbench", "guard-eval-log"),
|
|
3094
3713
|
phaseId,
|
|
3095
3714
|
narrated: input.narrated,
|
|
3096
3715
|
touchedInvariants: input.touchedInvariants
|