halfcycle 0.3.10 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bin.js +445 -254
- 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/engagement-credential.d.ts +4 -1
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/index.js +39 -60
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +36 -2
- 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";
|
|
@@ -849,7 +860,6 @@ var WRITE_ALLOWLIST = [
|
|
|
849
860
|
".halfcycle/bin",
|
|
850
861
|
"docs/build-records",
|
|
851
862
|
"test/fixtures/captured",
|
|
852
|
-
".env.example",
|
|
853
863
|
".mcp.json",
|
|
854
864
|
ZONE_B_DIR
|
|
855
865
|
];
|
|
@@ -1126,6 +1136,19 @@ function generateCiStanza() {
|
|
|
1126
1136
|
# .halfcycle/bin/, so it works in a Python or Go repository as well as a Node
|
|
1127
1137
|
# one \u2014 the only prerequisite is Node 20 to run the bundled binary.
|
|
1128
1138
|
#
|
|
1139
|
+
# THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
|
|
1140
|
+
# product talks to has one address, the same for every user, and it ships in the
|
|
1141
|
+
# tools you already have: the installer, the guard hook and the binary this job
|
|
1142
|
+
# runs each know where to go. If something tells you to set a Halfcycle URL, it is
|
|
1143
|
+
# out of date.
|
|
1144
|
+
#
|
|
1145
|
+
# AND THERE IS NOTHING TO SET ON YOUR OWN MACHINE EITHER. \`npx halfcycle\` wrote
|
|
1146
|
+
# this engagement's credential to $HOME/.halfcycle, owner-only, outside every
|
|
1147
|
+
# checkout, and the guard hook reads it from there. A CI job is the one place that
|
|
1148
|
+
# store cannot be reached \u2014 no browser, no per-user home \u2014 which is why the two
|
|
1149
|
+
# names below exist at all, and why they are the only two: a credential, and the id
|
|
1150
|
+
# of your own engagement.
|
|
1151
|
+
#
|
|
1129
1152
|
# WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
|
|
1130
1153
|
# commits pushed straight to the default branch, which is the shape most
|
|
1131
1154
|
# Halfcycle engagements settle on. \`fetch-depth\` is what makes that true: the
|
|
@@ -1162,43 +1185,6 @@ function generateCiStanza() {
|
|
|
1162
1185
|
# (on a GitHub push event, \${{ github.event.before }} is the right value).
|
|
1163
1186
|
`;
|
|
1164
1187
|
}
|
|
1165
|
-
var ENV_EXAMPLE_NAMES = [
|
|
1166
|
-
// The engagement's own secret, under the name the guard runner reads. A CI job
|
|
1167
|
-
// has no per-user credential store, so this is one of the two it must supply.
|
|
1168
|
-
"GUARD_SERVICE_TOKEN",
|
|
1169
|
-
// Which engagement this repository is. Per-engagement by construction; the same
|
|
1170
|
-
// CI job supplies it beside the token.
|
|
1171
|
-
"GUARD_ENGAGEMENT_ID",
|
|
1172
|
-
// The same credential under the name every other Halfcycle surface reads. Set in
|
|
1173
|
-
// the environment it takes precedence over the stored credential, which is the
|
|
1174
|
-
// escape hatch for running as a specific identity.
|
|
1175
|
-
"HALFCYCLE_TOKEN"
|
|
1176
|
-
];
|
|
1177
|
-
function generateEnvExampleSection() {
|
|
1178
|
-
return `
|
|
1179
|
-
# ---------------------------------------------------------------------------
|
|
1180
|
-
# Halfcycle (generated by the Halfcycle installer \u2014 names only, no values)
|
|
1181
|
-
# ---------------------------------------------------------------------------
|
|
1182
|
-
# YOU DO NOT NEED TO SET ANY OF THESE ON YOUR OWN MACHINE, and there is nowhere in
|
|
1183
|
-
# this repository to put them. \`npx halfcycle\` writes this engagement's credential
|
|
1184
|
-
# to $HOME/.halfcycle, owner-only, outside every checkout; the guard hook and the
|
|
1185
|
-
# MCP headers helper read it from there. Nothing Halfcycle writes a secret into
|
|
1186
|
-
# this tree, so there is no file here to leak, ignore or clean up.
|
|
1187
|
-
#
|
|
1188
|
-
# These three are declared because they ARE read from the environment in one place:
|
|
1189
|
-
# a CI job, which has no browser and no per-user store and supplies them as workflow
|
|
1190
|
-
# secrets (see .halfcycle/ci-stanza.yml). All three are yours \u2014 a credential and the
|
|
1191
|
-
# id of your engagement.
|
|
1192
|
-
#
|
|
1193
|
-
# THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
|
|
1194
|
-
# product talks to has one address, the same for every user, and it ships in the
|
|
1195
|
-
# tools you already have: the installer, the guard hook and the CI binary each know
|
|
1196
|
-
# where to go. If something tells you to set a Halfcycle URL, it is out of date.
|
|
1197
|
-
GUARD_SERVICE_TOKEN=<argType:runtime>
|
|
1198
|
-
GUARD_ENGAGEMENT_ID=<argType:runtime>
|
|
1199
|
-
HALFCYCLE_TOKEN=<argType:runtime>
|
|
1200
|
-
`;
|
|
1201
|
-
}
|
|
1202
1188
|
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
1203
1189
|
var MCP_SERVER_KEY = "halfcycle";
|
|
1204
1190
|
var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
@@ -1320,21 +1306,6 @@ function generateMcpRegistration(existing, mcpOrigin) {
|
|
|
1320
1306
|
};
|
|
1321
1307
|
return JSON.stringify(base, null, 2) + "\n";
|
|
1322
1308
|
}
|
|
1323
|
-
function reconcileEnvExample(targetRepo) {
|
|
1324
|
-
const envExamplePath = join5(targetRepo, ".env.example");
|
|
1325
|
-
const section = generateEnvExampleSection();
|
|
1326
|
-
if (!existsSync3(envExamplePath)) {
|
|
1327
|
-
mkdirSync4(dirname2(envExamplePath), { recursive: true });
|
|
1328
|
-
writeFileSync4(envExamplePath, section.trimStart(), "utf-8");
|
|
1329
|
-
return "written";
|
|
1330
|
-
}
|
|
1331
|
-
const existing = readFileSync5(envExamplePath, "utf-8");
|
|
1332
|
-
const missing = ENV_EXAMPLE_NAMES.filter((name) => !existing.includes(name));
|
|
1333
|
-
if (missing.length === 0)
|
|
1334
|
-
return "skipped";
|
|
1335
|
-
writeFileSync4(envExamplePath, existing + section, "utf-8");
|
|
1336
|
-
return "written";
|
|
1337
|
-
}
|
|
1338
1309
|
var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
|
|
1339
1310
|
function gitignoreCovers(content) {
|
|
1340
1311
|
const lines = new Set(content.split("\n").map((l) => l.trim()));
|
|
@@ -1363,12 +1334,24 @@ ${header}
|
|
|
1363
1334
|
return "failed";
|
|
1364
1335
|
}
|
|
1365
1336
|
}
|
|
1366
|
-
function
|
|
1337
|
+
function previousAccountId(targetRepoRoot, engagementId) {
|
|
1338
|
+
try {
|
|
1339
|
+
const existing = readBundlePin(targetRepoRoot);
|
|
1340
|
+
if (existing === null || existing.engagementId !== engagementId)
|
|
1341
|
+
return void 0;
|
|
1342
|
+
return existing.accountId;
|
|
1343
|
+
} catch {
|
|
1344
|
+
return void 0;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
|
|
1348
|
+
const carried = accountId ?? previousAccountId(targetRepoRoot, engagementId);
|
|
1367
1349
|
const pin = {
|
|
1368
1350
|
version,
|
|
1369
1351
|
engagementId,
|
|
1370
1352
|
engagementType,
|
|
1371
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1353
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1354
|
+
...carried !== void 0 && carried.trim() !== "" ? { accountId: carried } : {}
|
|
1372
1355
|
};
|
|
1373
1356
|
const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
|
|
1374
1357
|
writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
|
|
@@ -1523,7 +1506,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
|
|
|
1523
1506
|
}
|
|
1524
1507
|
}
|
|
1525
1508
|
async function install(options) {
|
|
1526
|
-
const { targetRepo, engagementId, engagementType, credential, home } = options;
|
|
1509
|
+
const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
|
|
1527
1510
|
if (!existsSync3(targetRepo)) {
|
|
1528
1511
|
throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
|
|
1529
1512
|
}
|
|
@@ -1565,7 +1548,6 @@ async function install(options) {
|
|
|
1565
1548
|
}
|
|
1566
1549
|
const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
|
|
1567
1550
|
record(report, writeCollisionSafe(ciStanzaPath, targetRepo, generateCiStanza()), ".halfcycle/ci-stanza.yml");
|
|
1568
|
-
record(report, reconcileEnvExample(targetRepo), ".env.example");
|
|
1569
1551
|
if (credential) {
|
|
1570
1552
|
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
1571
1553
|
record(report, writeCollisionSafe(helperPath, targetRepo, generateMcpHeadersHelper()), MCP_HEADERS_HELPER_REL);
|
|
@@ -1613,7 +1595,7 @@ async function install(options) {
|
|
|
1613
1595
|
case "absent":
|
|
1614
1596
|
break;
|
|
1615
1597
|
}
|
|
1616
|
-
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
|
|
1598
|
+
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
|
|
1617
1599
|
writeCrewRoster(targetRepo, report);
|
|
1618
1600
|
const scanResult = runBootstrapScan(targetRepo);
|
|
1619
1601
|
return {
|
|
@@ -1639,126 +1621,6 @@ function controlOriginNote(resolved) {
|
|
|
1639
1621
|
return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane)`;
|
|
1640
1622
|
}
|
|
1641
1623
|
|
|
1642
|
-
// dist/create-engagement.js
|
|
1643
|
-
var CreateEngagementRefused = class extends Error {
|
|
1644
|
-
status;
|
|
1645
|
-
serverMessage;
|
|
1646
|
-
errorCode;
|
|
1647
|
-
constructor(status, serverMessage, errorCode, message) {
|
|
1648
|
-
super(message);
|
|
1649
|
-
this.status = status;
|
|
1650
|
-
this.serverMessage = serverMessage;
|
|
1651
|
-
this.errorCode = errorCode;
|
|
1652
|
-
this.name = "CreateEngagementRefused";
|
|
1653
|
-
}
|
|
1654
|
-
/**
|
|
1655
|
-
* Is this a refusal of the CALLER's credential — the arm signing in again can fix?
|
|
1656
|
-
*
|
|
1657
|
-
* `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
|
|
1658
|
-
* the whole reason this is a method rather than a status comparison at the call
|
|
1659
|
-
* site: the flag gates the DEPLOYMENT, identically for every caller, so no
|
|
1660
|
-
* credential and no sign-in changes the answer. Treating it as an auth refusal
|
|
1661
|
-
* would send a developer through a browser handoff to arrive at the same 403.
|
|
1662
|
-
*
|
|
1663
|
-
* **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
|
|
1664
|
-
* (T-24).** On the join route a 403 has two causes that share a status AND a
|
|
1665
|
-
* discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
|
|
1666
|
-
* apart: the credential may be a live engagement token carrying no account, or the
|
|
1667
|
-
* engagement id may not resolve. Signing in again cannot fix the second, and
|
|
1668
|
-
* treating it as though it could would discard a good credential, walk the
|
|
1669
|
-
* developer through a browser, and arrive at the identical 403 — a remedy that
|
|
1670
|
-
* loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
|
|
1671
|
-
* where the reason can be written down. See `joinPinnedEngagement` there.
|
|
1672
|
-
*/
|
|
1673
|
-
get authRefused() {
|
|
1674
|
-
if (this.errorCode === "PublicStartDisabled")
|
|
1675
|
-
return false;
|
|
1676
|
-
return this.status === 401 || this.status === 403;
|
|
1677
|
-
}
|
|
1678
|
-
};
|
|
1679
|
-
function planeRefusal(detail) {
|
|
1680
|
-
let body;
|
|
1681
|
-
try {
|
|
1682
|
-
body = JSON.parse(detail);
|
|
1683
|
-
} catch {
|
|
1684
|
-
return void 0;
|
|
1685
|
-
}
|
|
1686
|
-
if (typeof body !== "object" || body === null)
|
|
1687
|
-
return void 0;
|
|
1688
|
-
const candidate = body;
|
|
1689
|
-
if (typeof candidate.message !== "string" || candidate.message.trim() === "")
|
|
1690
|
-
return void 0;
|
|
1691
|
-
const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
|
|
1692
|
-
if (!hasDiscriminant)
|
|
1693
|
-
return void 0;
|
|
1694
|
-
return {
|
|
1695
|
-
message: candidate.message,
|
|
1696
|
-
error: typeof candidate.error === "string" ? candidate.error : void 0
|
|
1697
|
-
};
|
|
1698
|
-
}
|
|
1699
|
-
var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
|
|
1700
|
-
async function createEngagement(baseUrl, name, credential) {
|
|
1701
|
-
return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
|
|
1702
|
-
}
|
|
1703
|
-
async function joinEngagement(baseUrl, engagementId, credential) {
|
|
1704
|
-
return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
|
|
1705
|
-
action: "Join-engagement",
|
|
1706
|
-
nothingHappened: "No credential was minted",
|
|
1707
|
-
route: "POST /engagements/:engagementId/join"
|
|
1708
|
-
});
|
|
1709
|
-
}
|
|
1710
|
-
async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
1711
|
-
const bearer = credential?.trim();
|
|
1712
|
-
let res;
|
|
1713
|
-
try {
|
|
1714
|
-
res = await fetch(url, {
|
|
1715
|
-
method: "POST",
|
|
1716
|
-
headers: {
|
|
1717
|
-
"content-type": "application/json",
|
|
1718
|
-
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
1719
|
-
},
|
|
1720
|
-
body: JSON.stringify(requestBody)
|
|
1721
|
-
});
|
|
1722
|
-
} catch (err) {
|
|
1723
|
-
throw new Error(`[bundle install] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
1724
|
-
}
|
|
1725
|
-
if (!res.ok) {
|
|
1726
|
-
const detail = await res.text().catch(() => "");
|
|
1727
|
-
const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
|
|
1728
|
-
if (refusal) {
|
|
1729
|
-
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
|
|
1730
|
-
}
|
|
1731
|
-
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
|
|
1732
|
-
throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
1733
|
-
}
|
|
1734
|
-
const body = await res.json().catch(() => null);
|
|
1735
|
-
if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
|
|
1736
|
-
throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
1737
|
-
}
|
|
1738
|
-
for (const [field, value] of [
|
|
1739
|
-
["engagementId", body.engagementId],
|
|
1740
|
-
["sessionToken", body.sessionToken]
|
|
1741
|
-
]) {
|
|
1742
|
-
if (value.trim() === "") {
|
|
1743
|
-
throw new Error(`[bundle install] The Halfcycle plane at ${url} answered with a blank ${field} (the field is present but empty). An install cannot proceed on it: it would be written into this engagement's credential store, read back as a configured value, and every guard evaluation would fail against it for the life of the engagement. ${shape.nothingHappened} and nothing was written. ` + CONTROL_ORIGIN_HINT);
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
|
|
1747
|
-
throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its MCP server lives (no mcpUrl on the response). Without that address the install would write an MCP registration pointing nowhere, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
|
|
1748
|
-
}
|
|
1749
|
-
if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
|
|
1750
|
-
throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
|
|
1751
|
-
}
|
|
1752
|
-
const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
|
|
1753
|
-
return {
|
|
1754
|
-
engagementId: body.engagementId,
|
|
1755
|
-
sessionToken: body.sessionToken,
|
|
1756
|
-
mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
|
|
1757
|
-
guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
|
|
1758
|
-
...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
|
|
1759
|
-
};
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
1624
|
// dist/device-signin.js
|
|
1763
1625
|
import { spawn } from "node:child_process";
|
|
1764
1626
|
import { platform as platform3 } from "node:os";
|
|
@@ -1769,8 +1631,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
1769
1631
|
var LOOPBACK_PORT_ENV = "HALFCYCLE_LOOPBACK_PORT";
|
|
1770
1632
|
var DEFAULT_STILL_WAITING_MS = 15e3;
|
|
1771
1633
|
function completionPage(result) {
|
|
1772
|
-
const
|
|
1773
|
-
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>`;
|
|
1774
1638
|
}
|
|
1775
1639
|
function isLoopbackPeer(remoteAddress) {
|
|
1776
1640
|
if (remoteAddress === void 0)
|
|
@@ -1841,14 +1705,14 @@ async function bindLoopback(env = process.env) {
|
|
|
1841
1705
|
else
|
|
1842
1706
|
pending = callback;
|
|
1843
1707
|
}));
|
|
1844
|
-
const bindProblem = await new Promise((
|
|
1708
|
+
const bindProblem = await new Promise((resolve3) => {
|
|
1845
1709
|
const onError = (err) => {
|
|
1846
|
-
|
|
1710
|
+
resolve3(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
|
|
1847
1711
|
};
|
|
1848
1712
|
server.once("error", onError);
|
|
1849
1713
|
server.listen({ host: LOOPBACK_REDIRECT_HOST, port: wanted.port }, () => {
|
|
1850
1714
|
server.removeListener("error", onError);
|
|
1851
|
-
|
|
1715
|
+
resolve3(null);
|
|
1852
1716
|
});
|
|
1853
1717
|
});
|
|
1854
1718
|
if (bindProblem !== null) {
|
|
@@ -1864,9 +1728,9 @@ async function bindLoopback(env = process.env) {
|
|
|
1864
1728
|
bound: true,
|
|
1865
1729
|
target: { port: address.port, state },
|
|
1866
1730
|
boundAddress: address.address,
|
|
1867
|
-
waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((
|
|
1731
|
+
waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve3) => {
|
|
1868
1732
|
if (pending !== null) {
|
|
1869
|
-
|
|
1733
|
+
resolve3(pending);
|
|
1870
1734
|
return;
|
|
1871
1735
|
}
|
|
1872
1736
|
const startedAt = Date.now();
|
|
@@ -1876,7 +1740,7 @@ async function bindLoopback(env = process.env) {
|
|
|
1876
1740
|
clearInterval(ticker);
|
|
1877
1741
|
clearTimeout(timer);
|
|
1878
1742
|
deliver = null;
|
|
1879
|
-
|
|
1743
|
+
resolve3(value);
|
|
1880
1744
|
};
|
|
1881
1745
|
const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
|
|
1882
1746
|
deliver = finish;
|
|
@@ -1885,9 +1749,9 @@ async function bindLoopback(env = process.env) {
|
|
|
1885
1749
|
};
|
|
1886
1750
|
}
|
|
1887
1751
|
function closeServer(server) {
|
|
1888
|
-
return new Promise((
|
|
1752
|
+
return new Promise((resolve3) => {
|
|
1889
1753
|
server.closeAllConnections();
|
|
1890
|
-
server.close(() =>
|
|
1754
|
+
server.close(() => resolve3());
|
|
1891
1755
|
});
|
|
1892
1756
|
}
|
|
1893
1757
|
|
|
@@ -1909,7 +1773,7 @@ function defaultWrite(text) {
|
|
|
1909
1773
|
process.stdout.write(text);
|
|
1910
1774
|
}
|
|
1911
1775
|
function defaultSleep(ms) {
|
|
1912
|
-
return new Promise((
|
|
1776
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1913
1777
|
}
|
|
1914
1778
|
function browserOpenCommand(os, url) {
|
|
1915
1779
|
if (os === "darwin")
|
|
@@ -1935,12 +1799,12 @@ async function openBrowser(url, env = process.env) {
|
|
|
1935
1799
|
return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
|
|
1936
1800
|
}
|
|
1937
1801
|
const { command, args: args2 } = browserOpenCommand(platform3(), url);
|
|
1938
|
-
return new Promise((
|
|
1802
|
+
return new Promise((resolve3) => {
|
|
1939
1803
|
let child;
|
|
1940
1804
|
try {
|
|
1941
1805
|
child = spawn(command, args2, { stdio: "ignore", env });
|
|
1942
1806
|
} catch (err) {
|
|
1943
|
-
|
|
1807
|
+
resolve3({
|
|
1944
1808
|
opened: false,
|
|
1945
1809
|
reason: `${command} could not be run (${err instanceof Error ? err.message : String(err)})`
|
|
1946
1810
|
});
|
|
@@ -1952,7 +1816,7 @@ async function openBrowser(url, env = process.env) {
|
|
|
1952
1816
|
return;
|
|
1953
1817
|
settled = true;
|
|
1954
1818
|
clearTimeout(timer);
|
|
1955
|
-
|
|
1819
|
+
resolve3(attempt);
|
|
1956
1820
|
};
|
|
1957
1821
|
const timer = setTimeout(() => {
|
|
1958
1822
|
child.unref();
|
|
@@ -2172,6 +2036,134 @@ async function deviceCodeSignIn(serviceUrl, started, env, deps) {
|
|
|
2172
2036
|
};
|
|
2173
2037
|
}
|
|
2174
2038
|
|
|
2039
|
+
// dist/create-engagement.js
|
|
2040
|
+
var CreateEngagementRefused = class extends Error {
|
|
2041
|
+
status;
|
|
2042
|
+
serverMessage;
|
|
2043
|
+
errorCode;
|
|
2044
|
+
constructor(status, serverMessage, errorCode, message) {
|
|
2045
|
+
super(message);
|
|
2046
|
+
this.status = status;
|
|
2047
|
+
this.serverMessage = serverMessage;
|
|
2048
|
+
this.errorCode = errorCode;
|
|
2049
|
+
this.name = "CreateEngagementRefused";
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Is this a refusal of the CALLER's credential — the arm signing in again can fix?
|
|
2053
|
+
*
|
|
2054
|
+
* `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
|
|
2055
|
+
* the whole reason this is a method rather than a status comparison at the call
|
|
2056
|
+
* site: the flag gates the DEPLOYMENT, identically for every caller, so no
|
|
2057
|
+
* credential and no sign-in changes the answer. Treating it as an auth refusal
|
|
2058
|
+
* would send a developer through a browser handoff to arrive at the same 403.
|
|
2059
|
+
*
|
|
2060
|
+
* **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
|
|
2061
|
+
* (T-24).** On the join route a 403 has two causes that share a status AND a
|
|
2062
|
+
* discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
|
|
2063
|
+
* apart: the credential may be a live engagement token carrying no account, or the
|
|
2064
|
+
* engagement id may not resolve. Signing in again cannot fix the second, and
|
|
2065
|
+
* treating it as though it could would discard a good credential, walk the
|
|
2066
|
+
* developer through a browser, and arrive at the identical 403 — a remedy that
|
|
2067
|
+
* loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
|
|
2068
|
+
* where the reason can be written down. See `joinPinnedEngagement` there.
|
|
2069
|
+
*/
|
|
2070
|
+
get authRefused() {
|
|
2071
|
+
if (this.errorCode === "PublicStartDisabled")
|
|
2072
|
+
return false;
|
|
2073
|
+
return this.status === 401 || this.status === 403;
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
function planeRefusal(detail) {
|
|
2077
|
+
let body;
|
|
2078
|
+
try {
|
|
2079
|
+
body = JSON.parse(detail);
|
|
2080
|
+
} catch {
|
|
2081
|
+
return void 0;
|
|
2082
|
+
}
|
|
2083
|
+
if (typeof body !== "object" || body === null)
|
|
2084
|
+
return void 0;
|
|
2085
|
+
const candidate = body;
|
|
2086
|
+
if (typeof candidate.message !== "string" || candidate.message.trim() === "")
|
|
2087
|
+
return void 0;
|
|
2088
|
+
const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
|
|
2089
|
+
if (!hasDiscriminant)
|
|
2090
|
+
return void 0;
|
|
2091
|
+
return {
|
|
2092
|
+
message: candidate.message,
|
|
2093
|
+
error: typeof candidate.error === "string" ? candidate.error : void 0
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
|
|
2097
|
+
async function createEngagement(baseUrl, name, credential, derivedName) {
|
|
2098
|
+
return requestEngagementValues(
|
|
2099
|
+
`${baseUrl.replace(/\/+$/, "")}/engagements`,
|
|
2100
|
+
// A stated name wins over a guess, and the body carries at most one of them —
|
|
2101
|
+
// the plane applies the same ordering, so the two cannot disagree about which
|
|
2102
|
+
// one a row was named from.
|
|
2103
|
+
name ? { name } : derivedName ? { derivedName } : {},
|
|
2104
|
+
credential,
|
|
2105
|
+
{ action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" }
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
async function joinEngagement(baseUrl, engagementId, credential) {
|
|
2109
|
+
return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
|
|
2110
|
+
action: "Join-engagement",
|
|
2111
|
+
nothingHappened: "No credential was minted",
|
|
2112
|
+
route: "POST /engagements/:engagementId/join"
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
2116
|
+
const bearer = credential?.trim();
|
|
2117
|
+
let res;
|
|
2118
|
+
try {
|
|
2119
|
+
res = await fetch(url, {
|
|
2120
|
+
method: "POST",
|
|
2121
|
+
headers: {
|
|
2122
|
+
"content-type": "application/json",
|
|
2123
|
+
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
2124
|
+
},
|
|
2125
|
+
body: JSON.stringify(requestBody)
|
|
2126
|
+
});
|
|
2127
|
+
} catch (err) {
|
|
2128
|
+
throw new Error(`[bundle install] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2129
|
+
}
|
|
2130
|
+
if (!res.ok) {
|
|
2131
|
+
const detail = await res.text().catch(() => "");
|
|
2132
|
+
const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
|
|
2133
|
+
if (refusal) {
|
|
2134
|
+
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
|
|
2135
|
+
}
|
|
2136
|
+
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
|
|
2137
|
+
throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2138
|
+
}
|
|
2139
|
+
const body = await res.json().catch(() => null);
|
|
2140
|
+
if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
|
|
2141
|
+
throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2142
|
+
}
|
|
2143
|
+
for (const [field, value] of [
|
|
2144
|
+
["engagementId", body.engagementId],
|
|
2145
|
+
["sessionToken", body.sessionToken]
|
|
2146
|
+
]) {
|
|
2147
|
+
if (value.trim() === "") {
|
|
2148
|
+
throw new Error(`[bundle install] The Halfcycle plane at ${url} answered with a blank ${field} (the field is present but empty). An install cannot proceed on it: it would be written into this engagement's credential store, read back as a configured value, and every guard evaluation would fail against it for the life of the engagement. ${shape.nothingHappened} and nothing was written. ` + CONTROL_ORIGIN_HINT);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
|
|
2152
|
+
throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its MCP server lives (no mcpUrl on the response). Without that address the install would write an MCP registration pointing nowhere, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
|
|
2153
|
+
}
|
|
2154
|
+
if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
|
|
2155
|
+
throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
|
|
2156
|
+
}
|
|
2157
|
+
const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
|
|
2158
|
+
return {
|
|
2159
|
+
engagementId: body.engagementId,
|
|
2160
|
+
sessionToken: body.sessionToken,
|
|
2161
|
+
mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
|
|
2162
|
+
guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
|
|
2163
|
+
...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2175
2167
|
// dist/resolve-credential.js
|
|
2176
2168
|
async function obtainCredential(serviceUrl, opts = {}) {
|
|
2177
2169
|
const env = opts.env ?? process.env;
|
|
@@ -2180,8 +2172,16 @@ async function obtainCredential(serviceUrl, opts = {}) {
|
|
|
2180
2172
|
return { credential: fromEnv, source: "environment" };
|
|
2181
2173
|
const stored = readStoredCredential(serviceUrl, opts.home);
|
|
2182
2174
|
if (stored) {
|
|
2183
|
-
return {
|
|
2175
|
+
return {
|
|
2176
|
+
credential: stored.credential,
|
|
2177
|
+
source: "stored",
|
|
2178
|
+
accountId: stored.accountId,
|
|
2179
|
+
signedInAt: stored.signedInAt
|
|
2180
|
+
};
|
|
2184
2181
|
}
|
|
2182
|
+
return signInAndStore(serviceUrl, opts);
|
|
2183
|
+
}
|
|
2184
|
+
async function signInAndStore(serviceUrl, opts) {
|
|
2185
2185
|
const signedIn = await signIn(serviceUrl, opts);
|
|
2186
2186
|
writeStoredCredential(serviceUrl, {
|
|
2187
2187
|
accountId: signedIn.accountId,
|
|
@@ -2194,21 +2194,15 @@ async function obtainCredential(serviceUrl, opts = {}) {
|
|
|
2194
2194
|
accountId: signedIn.accountId
|
|
2195
2195
|
};
|
|
2196
2196
|
}
|
|
2197
|
+
async function switchAccount(serviceUrl, opts = {}) {
|
|
2198
|
+
forgetStoredCredential(serviceUrl, opts.home);
|
|
2199
|
+
return signInAndStore(serviceUrl, opts);
|
|
2200
|
+
}
|
|
2197
2201
|
async function replaceRefusedCredential(serviceUrl, refused, opts = {}) {
|
|
2198
2202
|
if (refused.source !== "stored")
|
|
2199
2203
|
return null;
|
|
2200
2204
|
forgetStoredCredential(serviceUrl, opts.home);
|
|
2201
|
-
|
|
2202
|
-
writeStoredCredential(serviceUrl, {
|
|
2203
|
-
accountId: signedIn.accountId,
|
|
2204
|
-
credential: signedIn.credential,
|
|
2205
|
-
expiresAt: signedIn.expiresAt
|
|
2206
|
-
}, opts.home);
|
|
2207
|
-
return {
|
|
2208
|
-
credential: signedIn.credential,
|
|
2209
|
-
source: "browser-handoff",
|
|
2210
|
-
accountId: signedIn.accountId
|
|
2211
|
-
};
|
|
2205
|
+
return signInAndStore(serviceUrl, opts);
|
|
2212
2206
|
}
|
|
2213
2207
|
function refusedCredentialRemedy(source) {
|
|
2214
2208
|
switch (source) {
|
|
@@ -2221,6 +2215,238 @@ function refusedCredentialRemedy(source) {
|
|
|
2221
2215
|
}
|
|
2222
2216
|
}
|
|
2223
2217
|
|
|
2218
|
+
// dist/confirm-identity.js
|
|
2219
|
+
import { createInterface } from "node:readline/promises";
|
|
2220
|
+
var IDENTITY_TIMEOUT_MS = 5e3;
|
|
2221
|
+
var IDENTITY_DECLINED = "identity-declined";
|
|
2222
|
+
async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
|
|
2223
|
+
const url = `${serviceUrl.replace(/\/+$/, "")}/account`;
|
|
2224
|
+
const controller = new AbortController();
|
|
2225
|
+
const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
|
|
2226
|
+
try {
|
|
2227
|
+
const res = await fetchImpl(url, {
|
|
2228
|
+
headers: { authorization: `Bearer ${credential}` },
|
|
2229
|
+
signal: controller.signal
|
|
2230
|
+
});
|
|
2231
|
+
if (!res.ok)
|
|
2232
|
+
return void 0;
|
|
2233
|
+
const parsed = safeParseAccountIdentity(await res.json());
|
|
2234
|
+
return parsed.success ? parsed.data : void 0;
|
|
2235
|
+
} catch {
|
|
2236
|
+
return void 0;
|
|
2237
|
+
} finally {
|
|
2238
|
+
clearTimeout(timer);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
function accountLabel(identity, storedAccountId) {
|
|
2242
|
+
const accountId = identity?.accountId ?? storedAccountId;
|
|
2243
|
+
if (identity?.email !== void 0 && identity.email.trim() !== "") {
|
|
2244
|
+
return accountId !== void 0 ? `${identity.email} (account ${accountId})` : identity.email;
|
|
2245
|
+
}
|
|
2246
|
+
if (accountId !== void 0)
|
|
2247
|
+
return `account ${accountId} (no email on record for it)`;
|
|
2248
|
+
return "the account this credential belongs to, which this plane did not name";
|
|
2249
|
+
}
|
|
2250
|
+
function onlyDate(iso) {
|
|
2251
|
+
if (iso === void 0)
|
|
2252
|
+
return void 0;
|
|
2253
|
+
const match = /^(\d{4}-\d{2}-\d{2})/.exec(iso);
|
|
2254
|
+
return match?.[1] ?? iso;
|
|
2255
|
+
}
|
|
2256
|
+
function actClause(act) {
|
|
2257
|
+
return act.kind === "create" ? `create a NEW project for that account, and install into ${act.targetRepo}` : `join the project this repository is pinned to (${act.engagementId}) as that account, and install into ${act.targetRepo}`;
|
|
2258
|
+
}
|
|
2259
|
+
async function defaultAsk(question) {
|
|
2260
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2261
|
+
try {
|
|
2262
|
+
return await rl.question(question);
|
|
2263
|
+
} finally {
|
|
2264
|
+
rl.close();
|
|
2265
|
+
process.stdin.pause();
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function readDecision(answer) {
|
|
2269
|
+
const said = answer.trim().toLowerCase();
|
|
2270
|
+
if (said === "y" || said === "yes")
|
|
2271
|
+
return "proceed";
|
|
2272
|
+
if (said === "s" || said === "switch")
|
|
2273
|
+
return "switch-account";
|
|
2274
|
+
return "stop";
|
|
2275
|
+
}
|
|
2276
|
+
function actingAs(credential, identity) {
|
|
2277
|
+
const accountId = identity?.accountId ?? credential.accountId;
|
|
2278
|
+
return accountId === void 0 ? credential : { ...credential, accountId };
|
|
2279
|
+
}
|
|
2280
|
+
function armScript(credential, act, label, home) {
|
|
2281
|
+
switch (credential.source) {
|
|
2282
|
+
case "browser-handoff":
|
|
2283
|
+
return {
|
|
2284
|
+
preamble: `[halfcycle] Signed in as ${label}. This install will ${actClause(act)}.
|
|
2285
|
+
`,
|
|
2286
|
+
asks: false,
|
|
2287
|
+
noTerminalNote: "",
|
|
2288
|
+
switchOpening: "",
|
|
2289
|
+
stopRemedy: "",
|
|
2290
|
+
afterSwitch: ""
|
|
2291
|
+
};
|
|
2292
|
+
case "environment":
|
|
2293
|
+
return {
|
|
2294
|
+
preamble: `[halfcycle] HALFCYCLE_TOKEN is set in this environment, and this install will use it.
|
|
2295
|
+
[halfcycle] Account: ${label}
|
|
2296
|
+
[halfcycle] About to ${actClause(act)}
|
|
2297
|
+
[halfcycle] A variable exported by a shell profile is not a choice about this run \u2014 if that is not your account, the project will belong to it and not to you.
|
|
2298
|
+
`,
|
|
2299
|
+
asks: true,
|
|
2300
|
+
// Deliberately nothing. This is the arm's DESIGNED case — a run with no browser
|
|
2301
|
+
// and no human — the preamble has already named the account and the variable,
|
|
2302
|
+
// and a line about having nobody to ask would print on every CI job forever.
|
|
2303
|
+
noTerminalNote: "",
|
|
2304
|
+
switchOpening: `[halfcycle] Signing you in through your browser \u2014 this install will use that account instead.
|
|
2305
|
+
`,
|
|
2306
|
+
stopRemedy: `Stopped before anything was created \u2014 nothing was created and nothing was written. HALFCYCLE_TOKEN in this environment is the credential for ${label}, and it takes precedence over any saved sign-in \u2014 so unset it, or set it to your own account's credential, before running this again.`,
|
|
2307
|
+
// The switch is real for THIS run (the new credential is what gets used) and it
|
|
2308
|
+
// does not persist: `obtainCredential` reads the variable first, so the next run
|
|
2309
|
+
// is the same variable again. Said here rather than discovered tomorrow.
|
|
2310
|
+
afterSwitch: `[halfcycle] HALFCYCLE_TOKEN is still set in this environment and still wins over a saved sign-in \u2014 unset it, or the next run will use it again.
|
|
2311
|
+
`
|
|
2312
|
+
};
|
|
2313
|
+
case "stored": {
|
|
2314
|
+
const savedOn = onlyDate(credential.signedInAt);
|
|
2315
|
+
return {
|
|
2316
|
+
preamble: `[halfcycle] This machine already has a Halfcycle sign-in saved, and this install will use it.
|
|
2317
|
+
[halfcycle] Account: ${label}
|
|
2318
|
+
` + (savedOn !== void 0 ? `[halfcycle] Saved on this machine: ${savedOn}
|
|
2319
|
+
` : "") + `[halfcycle] About to ${actClause(act)}
|
|
2320
|
+
[halfcycle] If that is not your account, the project will belong to it and not to you \u2014 you would not see the project in your own Halfcycle.
|
|
2321
|
+
`,
|
|
2322
|
+
asks: true,
|
|
2323
|
+
// Not a question nobody will read — a record of what happened, the two ways to
|
|
2324
|
+
// make the next run act as somebody else (both executable from exactly the state
|
|
2325
|
+
// this is printed in), and where to look for the answer once this output is gone.
|
|
2326
|
+
noTerminalNote: `[halfcycle] Nothing here to ask at (no terminal), so this is continuing as that account.
|
|
2327
|
+
[halfcycle] To run as a different one, set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)} and run this again with a terminal to sign in.
|
|
2328
|
+
[halfcycle] When this install finishes, which account it used is recorded in .halfcycle/bundle.json, so it can be checked after this output is gone.
|
|
2329
|
+
`,
|
|
2330
|
+
switchOpening: `[halfcycle] Forgetting this machine's saved sign-in and signing you in through your browser.
|
|
2331
|
+
`,
|
|
2332
|
+
stopRemedy: `Stopped before anything was created \u2014 nothing was created and nothing was written. This machine's saved sign-in is for ${label}. Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
|
|
2333
|
+
afterSwitch: ""
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
|
|
2339
|
+
const write = deps.write ?? ((text) => process.stdout.write(text));
|
|
2340
|
+
const identity = await describeAccount(serviceUrl, credential.credential, deps.fetchImpl);
|
|
2341
|
+
const label = accountLabel(identity, credential.accountId);
|
|
2342
|
+
const acting = actingAs(credential, identity);
|
|
2343
|
+
const script = armScript(credential, act, label, deps.home);
|
|
2344
|
+
write(script.preamble);
|
|
2345
|
+
if (!script.asks)
|
|
2346
|
+
return acting;
|
|
2347
|
+
if (deps.assumeYes === true) {
|
|
2348
|
+
write(`[halfcycle] Continuing as that account without asking, because --yes was given.
|
|
2349
|
+
`);
|
|
2350
|
+
return acting;
|
|
2351
|
+
}
|
|
2352
|
+
const interactive = deps.interactive ?? (process.stdin.isTTY === true && !isNonInteractive(deps.env));
|
|
2353
|
+
if (!interactive) {
|
|
2354
|
+
if (script.noTerminalNote !== "")
|
|
2355
|
+
write(script.noTerminalNote);
|
|
2356
|
+
return acting;
|
|
2357
|
+
}
|
|
2358
|
+
const ask = deps.ask ?? defaultAsk;
|
|
2359
|
+
const decision = readDecision(await ask(`[halfcycle] Continue as that account? [y] yes [s] sign in as someone else [anything else] stop: `));
|
|
2360
|
+
if (decision === "proceed")
|
|
2361
|
+
return acting;
|
|
2362
|
+
if (decision === "switch-account") {
|
|
2363
|
+
write(script.switchOpening);
|
|
2364
|
+
const replacement = await switchAccount(serviceUrl, deps);
|
|
2365
|
+
const replacementIdentity = await describeAccount(serviceUrl, replacement.credential, deps.fetchImpl);
|
|
2366
|
+
write(`[halfcycle] Signed in as ${accountLabel(replacementIdentity, replacement.accountId)}. This install will ${actClause(act)}.
|
|
2367
|
+
`);
|
|
2368
|
+
if (script.afterSwitch !== "")
|
|
2369
|
+
write(script.afterSwitch);
|
|
2370
|
+
return actingAs(replacement, replacementIdentity);
|
|
2371
|
+
}
|
|
2372
|
+
throw new SignInRefused(IDENTITY_DECLINED, script.stopRemedy);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// dist/project-name.js
|
|
2376
|
+
import { basename, resolve } from "node:path";
|
|
2377
|
+
var MAX_DERIVED_NAME_LEN = 100;
|
|
2378
|
+
var EMPTY_SEGMENTS = /* @__PURE__ */ new Set(["", ".", "..", "/", "\\"]);
|
|
2379
|
+
function repositoryNameFromRemote(remote) {
|
|
2380
|
+
const trimmed = remote.trim();
|
|
2381
|
+
if (trimmed.length === 0)
|
|
2382
|
+
return void 0;
|
|
2383
|
+
const withoutTrailingSlash = trimmed.replace(/\/+$/, "");
|
|
2384
|
+
const lastSeparator = Math.max(withoutTrailingSlash.lastIndexOf("/"), withoutTrailingSlash.lastIndexOf(":"));
|
|
2385
|
+
const segment = withoutTrailingSlash.slice(lastSeparator + 1);
|
|
2386
|
+
const name = segment.replace(/\.git$/i, "").trim();
|
|
2387
|
+
return name.length > 0 ? name : void 0;
|
|
2388
|
+
}
|
|
2389
|
+
function deriveProjectName(targetRepoRoot) {
|
|
2390
|
+
const remote = readRemote(targetRepoRoot);
|
|
2391
|
+
const fromRemote = remote ? repositoryNameFromRemote(remote) : void 0;
|
|
2392
|
+
const candidate = fromRemote ?? basename(resolve(targetRepoRoot));
|
|
2393
|
+
const name = candidate.trim();
|
|
2394
|
+
if (EMPTY_SEGMENTS.has(name))
|
|
2395
|
+
return void 0;
|
|
2396
|
+
return name.slice(0, MAX_DERIVED_NAME_LEN);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
// dist/own-engagement.js
|
|
2400
|
+
async function createOwnedEngagement(serviceUrl, targetRepo, deps = {}) {
|
|
2401
|
+
let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "create", targetRepo }, deps);
|
|
2402
|
+
for (; ; ) {
|
|
2403
|
+
try {
|
|
2404
|
+
return {
|
|
2405
|
+
...await createEngagement(serviceUrl, void 0, credential.credential, deriveProjectName(targetRepo)),
|
|
2406
|
+
actingAccountId: credential.accountId
|
|
2407
|
+
};
|
|
2408
|
+
} catch (err) {
|
|
2409
|
+
if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
|
|
2410
|
+
throw err;
|
|
2411
|
+
const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
|
|
2412
|
+
if (replacement === null) {
|
|
2413
|
+
throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
|
|
2414
|
+
}
|
|
2415
|
+
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
2416
|
+
`);
|
|
2417
|
+
credential = replacement;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
var JOIN_REFUSED_LOCAL_REMEDY = "No credential was minted and nothing was written. This repository is pinned to that engagement by .halfcycle/bundle.json \u2014 check that the id in it is the one you meant. To start a NEW engagement here instead, remove that file and re-run.";
|
|
2422
|
+
async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps = {}) {
|
|
2423
|
+
let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "join", targetRepo, engagementId }, deps);
|
|
2424
|
+
for (; ; ) {
|
|
2425
|
+
try {
|
|
2426
|
+
return {
|
|
2427
|
+
...await joinEngagement(serviceUrl, engagementId, credential.credential),
|
|
2428
|
+
actingAccountId: credential.accountId
|
|
2429
|
+
};
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
if (!(err instanceof CreateEngagementRefused))
|
|
2432
|
+
throw err;
|
|
2433
|
+
if (err.status === 403) {
|
|
2434
|
+
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
|
|
2435
|
+
}
|
|
2436
|
+
if (err.status !== 401) {
|
|
2437
|
+
throw err;
|
|
2438
|
+
}
|
|
2439
|
+
const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
|
|
2440
|
+
if (replacement === null) {
|
|
2441
|
+
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
|
|
2442
|
+
}
|
|
2443
|
+
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
2444
|
+
`);
|
|
2445
|
+
credential = replacement;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2224
2450
|
// dist/mint-board-code.js
|
|
2225
2451
|
async function mintBoardEnterCode(baseUrl, sessionToken) {
|
|
2226
2452
|
const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
|
|
@@ -2553,14 +2779,14 @@ function renderBuildRecordMarkdown(record2) {
|
|
|
2553
2779
|
|
|
2554
2780
|
// dist/build-record/write.js
|
|
2555
2781
|
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2556
|
-
import { join as join7, resolve, relative as relative2, isAbsolute } from "node:path";
|
|
2782
|
+
import { join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
|
|
2557
2783
|
var BUILD_RECORD_DIR = join7("docs", "build-records");
|
|
2558
2784
|
function serialiseRecordJson(record2) {
|
|
2559
2785
|
return JSON.stringify(record2, null, 2) + "\n";
|
|
2560
2786
|
}
|
|
2561
2787
|
function writeBuildRecord(repoRoot, record2) {
|
|
2562
|
-
const outDir =
|
|
2563
|
-
const expected =
|
|
2788
|
+
const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
|
|
2789
|
+
const expected = resolve2(repoRoot, BUILD_RECORD_DIR);
|
|
2564
2790
|
const rel = relative2(expected, outDir);
|
|
2565
2791
|
if (rel !== "" || isAbsolute(rel)) {
|
|
2566
2792
|
throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
|
|
@@ -2834,7 +3060,10 @@ var PLACEHOLDERS = {
|
|
|
2834
3060
|
};
|
|
2835
3061
|
|
|
2836
3062
|
// dist/bin.js
|
|
2837
|
-
var
|
|
3063
|
+
var ASSUME_YES_FLAGS = /* @__PURE__ */ new Set(["--yes", "-y"]);
|
|
3064
|
+
var rawArgs = process.argv.slice(2);
|
|
3065
|
+
var assumeYes = rawArgs.some((a) => ASSUME_YES_FLAGS.has(a));
|
|
3066
|
+
var args = rawArgs.filter((a) => !ASSUME_YES_FLAGS.has(a));
|
|
2838
3067
|
var MIN_HEADERS_HELPER_VERSION = "2.1.118";
|
|
2839
3068
|
var MIN_HEADER_ROTATION_VERSION = "2.1.193";
|
|
2840
3069
|
function compareVersions(a, b) {
|
|
@@ -2873,6 +3102,7 @@ function reportClaudeCodeVersion() {
|
|
|
2873
3102
|
}
|
|
2874
3103
|
var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|client]
|
|
2875
3104
|
install into target (default: the current directory)
|
|
3105
|
+
--yes / -y: use this machine's saved sign-in without being asked
|
|
2876
3106
|
halfcycle check-drift <target-repo>
|
|
2877
3107
|
halfcycle build-record <phase-id> [--repo <root>]
|
|
2878
3108
|
halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
|
|
@@ -2886,49 +3116,6 @@ function isHalfcycleMonorepo(dir) {
|
|
|
2886
3116
|
return false;
|
|
2887
3117
|
}
|
|
2888
3118
|
}
|
|
2889
|
-
async function createOwnedEngagement(serviceUrl) {
|
|
2890
|
-
let credential = await obtainCredential(serviceUrl);
|
|
2891
|
-
for (; ; ) {
|
|
2892
|
-
try {
|
|
2893
|
-
return await createEngagement(serviceUrl, void 0, credential.credential);
|
|
2894
|
-
} catch (err) {
|
|
2895
|
-
if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
|
|
2896
|
-
throw err;
|
|
2897
|
-
const replacement = await replaceRefusedCredential(serviceUrl, credential);
|
|
2898
|
-
if (replacement === null) {
|
|
2899
|
-
throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
|
|
2900
|
-
}
|
|
2901
|
-
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
2902
|
-
`);
|
|
2903
|
-
credential = replacement;
|
|
2904
|
-
}
|
|
2905
|
-
}
|
|
2906
|
-
}
|
|
2907
|
-
var JOIN_REFUSED_LOCAL_REMEDY = "No credential was minted and nothing was written. This repository is pinned to that engagement by .halfcycle/bundle.json \u2014 check that the id in it is the one you meant. To start a NEW engagement here instead, remove that file and re-run.";
|
|
2908
|
-
async function joinPinnedEngagement(serviceUrl, engagementId) {
|
|
2909
|
-
let credential = await obtainCredential(serviceUrl);
|
|
2910
|
-
for (; ; ) {
|
|
2911
|
-
try {
|
|
2912
|
-
return await joinEngagement(serviceUrl, engagementId, credential.credential);
|
|
2913
|
-
} catch (err) {
|
|
2914
|
-
if (!(err instanceof CreateEngagementRefused))
|
|
2915
|
-
throw err;
|
|
2916
|
-
if (err.status === 403) {
|
|
2917
|
-
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
|
|
2918
|
-
}
|
|
2919
|
-
if (err.status !== 401) {
|
|
2920
|
-
throw err;
|
|
2921
|
-
}
|
|
2922
|
-
const replacement = await replaceRefusedCredential(serviceUrl, credential);
|
|
2923
|
-
if (replacement === null) {
|
|
2924
|
-
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
|
|
2925
|
-
}
|
|
2926
|
-
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
2927
|
-
`);
|
|
2928
|
-
credential = replacement;
|
|
2929
|
-
}
|
|
2930
|
-
}
|
|
2931
|
-
}
|
|
2932
3119
|
async function main() {
|
|
2933
3120
|
const [cmd, ...rest] = args;
|
|
2934
3121
|
if (cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
@@ -2971,6 +3158,7 @@ ${USAGE}`);
|
|
|
2971
3158
|
const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
|
|
2972
3159
|
let engagementId;
|
|
2973
3160
|
let credential;
|
|
3161
|
+
let actingAccountId;
|
|
2974
3162
|
if (reusable !== void 0 && pinned !== null) {
|
|
2975
3163
|
engagementId = pinned.engagementId;
|
|
2976
3164
|
credential = reusable;
|
|
@@ -2984,8 +3172,9 @@ ${USAGE}`);
|
|
|
2984
3172
|
`);
|
|
2985
3173
|
}
|
|
2986
3174
|
} else if (requestedId !== void 0) {
|
|
2987
|
-
const joined = await joinPinnedEngagement(serviceUrl, requestedId);
|
|
3175
|
+
const joined = await joinPinnedEngagement(serviceUrl, requestedId, targetRepo, { assumeYes });
|
|
2988
3176
|
engagementId = joined.engagementId;
|
|
3177
|
+
actingAccountId = joined.actingAccountId;
|
|
2989
3178
|
credential = {
|
|
2990
3179
|
serviceUrl,
|
|
2991
3180
|
token: joined.sessionToken,
|
|
@@ -2999,8 +3188,9 @@ ${USAGE}`);
|
|
|
2999
3188
|
process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
|
|
3000
3189
|
`);
|
|
3001
3190
|
} else {
|
|
3002
|
-
const created = await createOwnedEngagement(serviceUrl);
|
|
3191
|
+
const created = await createOwnedEngagement(serviceUrl, targetRepo, { assumeYes });
|
|
3003
3192
|
engagementId = created.engagementId;
|
|
3193
|
+
actingAccountId = created.actingAccountId;
|
|
3004
3194
|
credential = {
|
|
3005
3195
|
serviceUrl,
|
|
3006
3196
|
token: created.sessionToken,
|
|
@@ -3021,7 +3211,8 @@ ${USAGE}`);
|
|
|
3021
3211
|
targetRepo,
|
|
3022
3212
|
engagementId,
|
|
3023
3213
|
engagementType: engagementTypeRaw,
|
|
3024
|
-
credential
|
|
3214
|
+
credential,
|
|
3215
|
+
accountId: actingAccountId
|
|
3025
3216
|
});
|
|
3026
3217
|
process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
|
|
3027
3218
|
`);
|