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