halfcycle 0.3.36 → 0.3.38
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/README.md +5 -1
- package/bin/bin.bundle.mjs +266 -88
- package/dist/bin.js +382 -221
- package/dist/bin.js.map +3 -3
- package/dist/build-record/obtain.d.ts +7 -3
- package/dist/build-record/obtain.d.ts.map +1 -1
- package/dist/build-record/render-v2.d.ts +4 -3
- package/dist/build-record/render-v2.d.ts.map +1 -1
- package/dist/cli-contract.d.ts +7 -0
- package/dist/cli-contract.d.ts.map +1 -1
- package/dist/control-origin.d.ts +2 -2
- package/dist/create-engagement.d.ts +27 -1
- package/dist/create-engagement.d.ts.map +1 -1
- package/dist/device-signin.d.ts +93 -17
- package/dist/device-signin.d.ts.map +1 -1
- package/dist/engagement-credential.d.ts +8 -4
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +211 -122
- package/dist/index.js.map +3 -3
- package/dist/install.d.ts +35 -9
- package/dist/install.d.ts.map +1 -1
- package/dist/loopback-signin.d.ts +8 -2
- package/dist/loopback-signin.d.ts.map +1 -1
- package/dist/open-phase.d.ts +12 -3
- package/dist/open-phase.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/mint-board-code.d.ts +0 -41
- package/dist/mint-board-code.d.ts.map +0 -1
package/dist/bin.js
CHANGED
|
@@ -230,6 +230,15 @@ function looksLikeSecret(text) {
|
|
|
230
230
|
return false;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// ../core/dist/mcp-registration.js
|
|
234
|
+
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
235
|
+
var MCP_SERVER_KEY = "halfcycle";
|
|
236
|
+
|
|
237
|
+
// ../core/dist/shell-quote.js
|
|
238
|
+
function quoteForShell(value) {
|
|
239
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
240
|
+
}
|
|
241
|
+
|
|
233
242
|
// ../events/dist/result.js
|
|
234
243
|
import { z as z2 } from "zod";
|
|
235
244
|
var firedGuardSchema = z2.object({
|
|
@@ -602,9 +611,14 @@ var closedBySchema = z4.discriminatedUnion("kind", [
|
|
|
602
611
|
z4.object({ kind: z4.literal("account-removed") }).strict(),
|
|
603
612
|
z4.object({ kind: z4.literal("not-recorded") }).strict()
|
|
604
613
|
]);
|
|
614
|
+
var STEP_NAME_MAX = 200;
|
|
615
|
+
var stepNameSchema = plainText(STEP_NAME_MAX).refine((v) => v.trim().length > 0, {
|
|
616
|
+
message: "must not be blank"
|
|
617
|
+
});
|
|
605
618
|
var unfinishedStepSchema = z4.object({
|
|
606
619
|
stepId: plainText(),
|
|
607
|
-
state: z4.enum(["parked", "failed"])
|
|
620
|
+
state: z4.enum(["parked", "failed"]),
|
|
621
|
+
name: stepNameSchema.optional()
|
|
608
622
|
}).strict();
|
|
609
623
|
var crossedSchema = z4.discriminatedUnion("kind", [
|
|
610
624
|
z4.object({ kind: z4.literal("none") }).strict(),
|
|
@@ -1213,14 +1227,28 @@ var engagementCredentialResponseSchema = z10.object({
|
|
|
1213
1227
|
sessionToken: z10.string(),
|
|
1214
1228
|
mcpUrl: z10.string(),
|
|
1215
1229
|
guardUrl: z10.string(),
|
|
1216
|
-
controlTelemetryUrl: z10.string()
|
|
1230
|
+
controlTelemetryUrl: z10.string(),
|
|
1231
|
+
methodVersion: z10.string().optional(),
|
|
1232
|
+
consoleUrl: z10.string().optional()
|
|
1217
1233
|
}).strict();
|
|
1218
1234
|
var ciTokenExchangeResponseSchema = z10.object({
|
|
1219
1235
|
token: z10.string(),
|
|
1220
1236
|
expiresAt: utcIso8601,
|
|
1221
1237
|
engagementId: z10.string()
|
|
1222
1238
|
}).strict();
|
|
1239
|
+
var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
|
|
1240
|
+
var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
|
|
1241
|
+
var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
|
|
1223
1242
|
var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
|
|
1243
|
+
var NO_CREDENTIAL_SENTENCE = 'Halfcycle is not connected: this machine has no working Halfcycle credential for this project. Run "npx halfcycle" in the project folder to sign in again.';
|
|
1244
|
+
var NOT_THIS_ACCOUNT_SENTENCE = `Halfcycle is not connected: this project belongs to another Halfcycle account. To switch, run "npx halfcycle" in this project and ${SWITCH_ACCOUNT_ANSWER}.`;
|
|
1245
|
+
var UPDATE_CLAUDE_CODE_SENTENCE = "Halfcycle is not connected: this Claude Code version does not name the server it connects to, so no credential was sent. Update Claude Code, then reopen.";
|
|
1246
|
+
var NO_NODE_SENTENCE = "Halfcycle is not connected: Node is not installed, or not on the path this session uses, so nothing here is checked. Install Node, then reopen.";
|
|
1247
|
+
var PLACEHOLDER_SENTENCES = Object.freeze({
|
|
1248
|
+
[NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
|
|
1249
|
+
[NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
|
|
1250
|
+
[UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
|
|
1251
|
+
});
|
|
1224
1252
|
|
|
1225
1253
|
// ../events/dist/phase-record.js
|
|
1226
1254
|
import { z as z11 } from "zod";
|
|
@@ -2001,7 +2029,7 @@ var WRITE_ALLOWLIST = [
|
|
|
2001
2029
|
".halfcycle/bin",
|
|
2002
2030
|
"docs/build-records",
|
|
2003
2031
|
"test/fixtures/captured",
|
|
2004
|
-
|
|
2032
|
+
MCP_REGISTRATION_REL,
|
|
2005
2033
|
".gitattributes",
|
|
2006
2034
|
ZONE_B_DIR
|
|
2007
2035
|
];
|
|
@@ -2374,6 +2402,20 @@ exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
|
2374
2402
|
}
|
|
2375
2403
|
var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
|
|
2376
2404
|
var SESSION_ID_FROM_HOOK_INPUT_SH = `SESSION_ID="$(printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(typeof j.session_id==="string"?j.session_id:"")}catch{process.stdout.write("")}})' 2>/dev/null || true)"`;
|
|
2405
|
+
var REPO_HASH_FROM_ROOT_SH = `HASH="$(printf '%s' "$REPO_ROOT" | node -e 'const c=[];process.stdin.on("data",d=>c.push(d)).on("end",()=>{process.stdout.write(require("node:crypto").createHash("sha256").update(Buffer.concat(c)).digest("hex").slice(0,12))})' 2>/dev/null || true)"`;
|
|
2406
|
+
function noNodeReplyShell() {
|
|
2407
|
+
const reply = JSON.stringify({
|
|
2408
|
+
systemMessage: NO_NODE_SENTENCE,
|
|
2409
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: NO_NODE_SENTENCE }
|
|
2410
|
+
});
|
|
2411
|
+
if (reply.includes("'")) {
|
|
2412
|
+
throw new Error("[bundle install] the no-Node notice cannot be single-quoted; refusing to write the session-start script.");
|
|
2413
|
+
}
|
|
2414
|
+
return `if ! command -v node >/dev/null 2>&1; then
|
|
2415
|
+
printf '%s\\n' '${reply}'
|
|
2416
|
+
exit 0
|
|
2417
|
+
fi`;
|
|
2418
|
+
}
|
|
2377
2419
|
function generateSessionStartMarker() {
|
|
2378
2420
|
return `#!/usr/bin/env bash
|
|
2379
2421
|
${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
@@ -2382,10 +2424,15 @@ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
|
2382
2424
|
# it began instead of only against the latest commit. The session id comes in on
|
|
2383
2425
|
# stdin with the hook input; without one, the older session-less name is written
|
|
2384
2426
|
# and the stop-time check compares against the latest commit as it used to.
|
|
2427
|
+
#
|
|
2428
|
+
# Without Node nothing Halfcycle installs here can run, so this says so once and
|
|
2429
|
+
# writes no marker.
|
|
2430
|
+
${noNodeReplyShell()}
|
|
2385
2431
|
INPUT="$(cat 2>/dev/null || true)"
|
|
2386
2432
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
2387
2433
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
2388
|
-
|
|
2434
|
+
${REPO_HASH_FROM_ROOT_SH}
|
|
2435
|
+
if [ -z "$HASH" ]; then exit 0; fi
|
|
2389
2436
|
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
2390
2437
|
MARKER_DIR="\${TMPDIR:-/tmp}"
|
|
2391
2438
|
MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
|
|
@@ -2395,64 +2442,6 @@ else
|
|
|
2395
2442
|
MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
|
|
2396
2443
|
fi
|
|
2397
2444
|
git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
|
|
2398
|
-
|
|
2399
|
-
# ---------------------------------------------------------------------------
|
|
2400
|
-
# STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
|
|
2401
|
-
#
|
|
2402
|
-
# A repository with no credential on this machine is UNGUARDED: the PostToolUse
|
|
2403
|
-
# hook will find nothing to evaluate with, and it says so once per edit, after
|
|
2404
|
-
# the edit. This says it once, up front, before anything is written \u2014 which is
|
|
2405
|
-
# the one thing a session-start hook can do that nothing else can.
|
|
2406
|
-
#
|
|
2407
|
-
# TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
|
|
2408
|
-
# solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
|
|
2409
|
-
# fixed by re-running the installer; "this is not your project" is permanent,
|
|
2410
|
-
# and the same remedy would send the reader in a circle forever. The two are
|
|
2411
|
-
# told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
|
|
2412
|
-
# below from a machine-level marker beside the credential store.
|
|
2413
|
-
#
|
|
2414
|
-
# Everything below runs in a SUBSHELL: it sources a credential store, and a
|
|
2415
|
-
# session-start hook must not leak an engagement's variables into whatever the
|
|
2416
|
-
# editor runs next. Any failure inside it is swallowed; this script's exit
|
|
2417
|
-
# status is 0 either way, and the marker above has already been written.
|
|
2418
|
-
# ---------------------------------------------------------------------------
|
|
2419
|
-
${engagementResolutionShell()}
|
|
2420
|
-
|
|
2421
|
-
(
|
|
2422
|
-
if halfcycle_env_file "$REPO_ROOT"; then
|
|
2423
|
-
set -a
|
|
2424
|
-
. "$HALFCYCLE_ENV_FILE"
|
|
2425
|
-
set +a
|
|
2426
|
-
fi
|
|
2427
|
-
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
2428
|
-
# OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
|
|
2429
|
-
# case below wearing different words: there is nothing to fix, and the loop
|
|
2430
|
-
# that follows is skipped entirely rather than run and then overridden \u2014 a
|
|
2431
|
-
# repository whose credential is absent for THIS reason must never be told
|
|
2432
|
-
# to re-run the installer, which can never obtain one for it.
|
|
2433
|
-
echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
|
|
2434
|
-
echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
|
|
2435
|
-
echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
|
|
2436
|
-
echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
|
|
2437
|
-
echo "[halfcycle] Ask the project's owner for view access."
|
|
2438
|
-
echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
|
|
2439
|
-
echo "[halfcycle] \\"npx halfcycle\\" again."
|
|
2440
|
-
else
|
|
2441
|
-
hc_missing=""
|
|
2442
|
-
for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
|
|
2443
|
-
eval "hc_value=\\\${$hc_key:-}"
|
|
2444
|
-
if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
|
|
2445
|
-
done
|
|
2446
|
-
if [ -n "$hc_missing" ]; then
|
|
2447
|
-
echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
|
|
2448
|
-
echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
|
|
2449
|
-
echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
|
|
2450
|
-
echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
|
|
2451
|
-
echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
|
|
2452
|
-
echo "[halfcycle] and needs nothing configured first."
|
|
2453
|
-
fi
|
|
2454
|
-
fi
|
|
2455
|
-
) 2>/dev/null || true
|
|
2456
2445
|
exit 0
|
|
2457
2446
|
`;
|
|
2458
2447
|
}
|
|
@@ -2468,7 +2457,8 @@ INPUT="$(cat 2>/dev/null || true)"
|
|
|
2468
2457
|
|
|
2469
2458
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
2470
2459
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
2471
|
-
|
|
2460
|
+
${REPO_HASH_FROM_ROOT_SH}
|
|
2461
|
+
if [ -z "$HASH" ]; then exit 0; fi
|
|
2472
2462
|
|
|
2473
2463
|
# The session id arrives on stdin as part of the hook payload. Empty when absent.
|
|
2474
2464
|
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
@@ -2588,8 +2578,6 @@ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeade
|
|
|
2588
2578
|
}
|
|
2589
2579
|
return "removed";
|
|
2590
2580
|
}
|
|
2591
|
-
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
2592
|
-
var MCP_SERVER_KEY = "halfcycle";
|
|
2593
2581
|
var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
|
|
2594
2582
|
`const p=require('path'),f=require('fs'),c=require('child_process');`,
|
|
2595
2583
|
`let d=process.cwd();`,
|
|
@@ -2732,9 +2720,10 @@ function previousPinForEngagement(targetRepoRoot, engagementId) {
|
|
|
2732
2720
|
function nonEmpty(value) {
|
|
2733
2721
|
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
2734
2722
|
}
|
|
2735
|
-
function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
|
|
2723
|
+
function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, methodVersion, writtenPaths) {
|
|
2736
2724
|
const previous = previousPinForEngagement(targetRepoRoot, engagementId);
|
|
2737
2725
|
const carried = nonEmpty(accountId) ?? nonEmpty(previous?.accountId);
|
|
2726
|
+
const carriedMethodVersion = nonEmpty(methodVersion) ?? nonEmpty(previous?.methodVersion);
|
|
2738
2727
|
const installedAt = nonEmpty(previous?.installedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2739
2728
|
const projectPage = projectPageUrl(engagementId);
|
|
2740
2729
|
const pin = {
|
|
@@ -2743,6 +2732,7 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, a
|
|
|
2743
2732
|
engagementType,
|
|
2744
2733
|
installedAt,
|
|
2745
2734
|
...carried !== void 0 ? { accountId: carried } : {},
|
|
2735
|
+
...carriedMethodVersion !== void 0 ? { methodVersion: carriedMethodVersion } : {},
|
|
2746
2736
|
...projectPage !== void 0 ? { projectPage } : {}
|
|
2747
2737
|
};
|
|
2748
2738
|
const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
|
|
@@ -2818,7 +2808,7 @@ function checkDrift(targetRepoRoot) {
|
|
|
2818
2808
|
const pin = readBundlePin(targetRepoRoot);
|
|
2819
2809
|
const installed = pin?.version ?? null;
|
|
2820
2810
|
const current = manifest.version;
|
|
2821
|
-
return { drifted: installed !== current, installed, current };
|
|
2811
|
+
return { drifted: installed !== current, installed, current, methodVersion: pin?.methodVersion ?? null };
|
|
2822
2812
|
}
|
|
2823
2813
|
function writeProjectIdentity(targetRepo) {
|
|
2824
2814
|
const path = join5(targetRepo, PROJECT_IDENTITY_REL);
|
|
@@ -3049,7 +3039,7 @@ async function install(options) {
|
|
|
3049
3039
|
case "absent":
|
|
3050
3040
|
break;
|
|
3051
3041
|
}
|
|
3052
|
-
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
|
|
3042
|
+
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, credential?.methodVersion, report.writtenPaths);
|
|
3053
3043
|
noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
|
|
3054
3044
|
noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
|
|
3055
3045
|
const scanResult = runBootstrapScan(targetRepo);
|
|
@@ -3180,6 +3170,7 @@ async function bindLoopback(env = process.env, engagementId) {
|
|
|
3180
3170
|
const state = randomBytes(32).toString("hex");
|
|
3181
3171
|
let pending = null;
|
|
3182
3172
|
let deliver = null;
|
|
3173
|
+
let abandon = null;
|
|
3183
3174
|
const server = createServer(createCallbackHandler(state, (callback) => {
|
|
3184
3175
|
if (deliver)
|
|
3185
3176
|
deliver(callback);
|
|
@@ -3221,12 +3212,18 @@ async function bindLoopback(env = process.env, engagementId) {
|
|
|
3221
3212
|
clearInterval(ticker);
|
|
3222
3213
|
clearTimeout(timer);
|
|
3223
3214
|
deliver = null;
|
|
3215
|
+
abandon = null;
|
|
3224
3216
|
resolve4(value);
|
|
3225
3217
|
};
|
|
3226
3218
|
const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
|
|
3227
3219
|
deliver = finish;
|
|
3220
|
+
abandon = () => finish(null);
|
|
3228
3221
|
}),
|
|
3229
|
-
close: () =>
|
|
3222
|
+
close: () => {
|
|
3223
|
+
if (abandon !== null)
|
|
3224
|
+
abandon();
|
|
3225
|
+
return closeServer(server);
|
|
3226
|
+
}
|
|
3230
3227
|
};
|
|
3231
3228
|
}
|
|
3232
3229
|
function closeServer(server) {
|
|
@@ -3260,8 +3257,13 @@ function defaultSleep(ms) {
|
|
|
3260
3257
|
function browserOpenCommand(os, url) {
|
|
3261
3258
|
if (os === "darwin")
|
|
3262
3259
|
return { command: "open", args: [url] };
|
|
3263
|
-
if (os === "win32")
|
|
3264
|
-
return {
|
|
3260
|
+
if (os === "win32") {
|
|
3261
|
+
return {
|
|
3262
|
+
command: "cmd.exe",
|
|
3263
|
+
args: ["/d", "/s", "/c", `"start "" "${url}""`],
|
|
3264
|
+
windowsVerbatimArguments: true
|
|
3265
|
+
};
|
|
3266
|
+
}
|
|
3265
3267
|
return { command: "xdg-open", args: [url] };
|
|
3266
3268
|
}
|
|
3267
3269
|
function isOpenableUrl(url) {
|
|
@@ -3272,19 +3274,35 @@ function isOpenableUrl(url) {
|
|
|
3272
3274
|
return false;
|
|
3273
3275
|
}
|
|
3274
3276
|
}
|
|
3277
|
+
function hasWin32UnsafeOpenerChars(url) {
|
|
3278
|
+
if (url.includes('"') || url.includes("%"))
|
|
3279
|
+
return true;
|
|
3280
|
+
for (let i = 0; i < url.length; i++) {
|
|
3281
|
+
const code = url.charCodeAt(i);
|
|
3282
|
+
if (code <= 31 || code === 127)
|
|
3283
|
+
return true;
|
|
3284
|
+
}
|
|
3285
|
+
return false;
|
|
3286
|
+
}
|
|
3275
3287
|
var BROWSER_OPEN_GRACE_MS = 3e3;
|
|
3276
|
-
async function openBrowser(url, env = process.env) {
|
|
3288
|
+
async function openBrowser(url, env = process.env, os = platform3(), spawnImpl = spawn) {
|
|
3277
3289
|
if ((env["HALFCYCLE_NO_BROWSER"] ?? "").trim() !== "") {
|
|
3278
3290
|
return { opened: false, reason: "HALFCYCLE_NO_BROWSER is set in this environment" };
|
|
3279
3291
|
}
|
|
3280
3292
|
if (!isOpenableUrl(url)) {
|
|
3281
3293
|
return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
|
|
3282
3294
|
}
|
|
3283
|
-
|
|
3295
|
+
if (os === "win32" && hasWin32UnsafeOpenerChars(url)) {
|
|
3296
|
+
return {
|
|
3297
|
+
opened: false,
|
|
3298
|
+
reason: 'the sign-in address holds a " or %, or a control character, this opener will not pass to cmd.exe on Windows'
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
3301
|
+
const { command, args: args2, windowsVerbatimArguments } = browserOpenCommand(os, url);
|
|
3284
3302
|
return new Promise((resolve4) => {
|
|
3285
3303
|
let child;
|
|
3286
3304
|
try {
|
|
3287
|
-
child =
|
|
3305
|
+
child = spawnImpl(command, args2, { stdio: "ignore", env, windowsVerbatimArguments });
|
|
3288
3306
|
} catch (err) {
|
|
3289
3307
|
resolve4({
|
|
3290
3308
|
opened: false,
|
|
@@ -3356,82 +3374,107 @@ function tryRefusal(body) {
|
|
|
3356
3374
|
}
|
|
3357
3375
|
return { status: candidate.status, message: candidate.message };
|
|
3358
3376
|
}
|
|
3377
|
+
async function askControlOnce(tokenUrl, started) {
|
|
3378
|
+
let body;
|
|
3379
|
+
try {
|
|
3380
|
+
const res = await fetch(tokenUrl, {
|
|
3381
|
+
method: "POST",
|
|
3382
|
+
headers: { "content-type": "application/json" },
|
|
3383
|
+
body: JSON.stringify({ deviceCode: started.deviceCode })
|
|
3384
|
+
});
|
|
3385
|
+
body = await res.json().catch(() => null);
|
|
3386
|
+
} catch {
|
|
3387
|
+
return null;
|
|
3388
|
+
}
|
|
3389
|
+
const parsed = devicePollResponseSchema.safeParse(body);
|
|
3390
|
+
return parsed.success ? parsed.data : null;
|
|
3391
|
+
}
|
|
3392
|
+
function tokenUrlFor(serviceUrl) {
|
|
3393
|
+
return `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
|
|
3394
|
+
}
|
|
3395
|
+
function localDeadline(started) {
|
|
3396
|
+
return new Date(started.expiresAt).getTime() + started.pollIntervalMs * DEADLINE_SLACK_INTERVALS;
|
|
3397
|
+
}
|
|
3359
3398
|
async function pollForCredential(serviceUrl, started, deps = {}) {
|
|
3360
3399
|
const write = deps.write ?? defaultWrite;
|
|
3361
3400
|
const sleep = deps.sleep ?? defaultSleep;
|
|
3362
3401
|
const now = deps.now ?? Date.now;
|
|
3363
|
-
const url =
|
|
3402
|
+
const url = tokenUrlFor(serviceUrl);
|
|
3364
3403
|
const interval = started.pollIntervalMs;
|
|
3365
|
-
const deadline =
|
|
3404
|
+
const deadline = localDeadline(started);
|
|
3366
3405
|
const startedAt = now();
|
|
3367
3406
|
let announcedAt = startedAt;
|
|
3368
3407
|
for (; ; ) {
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
body: JSON.stringify({ deviceCode: started.deviceCode })
|
|
3376
|
-
});
|
|
3377
|
-
body = await res.json().catch(() => null);
|
|
3378
|
-
} catch {
|
|
3379
|
-
reachable = false;
|
|
3380
|
-
}
|
|
3381
|
-
if (reachable) {
|
|
3382
|
-
const parsed = devicePollResponseSchema.safeParse(body);
|
|
3383
|
-
if (parsed.success) {
|
|
3384
|
-
const answer = parsed.data;
|
|
3385
|
-
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3386
|
-
return answer;
|
|
3387
|
-
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3388
|
-
throw new SignInRefused(answer.status, answer.message);
|
|
3389
|
-
}
|
|
3408
|
+
const answer = await askControlOnce(url, started);
|
|
3409
|
+
if (answer !== null) {
|
|
3410
|
+
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3411
|
+
return answer;
|
|
3412
|
+
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3413
|
+
throw new SignInRefused(answer.status, answer.message);
|
|
3390
3414
|
}
|
|
3391
3415
|
}
|
|
3392
3416
|
if (now() >= deadline) {
|
|
3393
3417
|
throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_TIMEOUT_MESSAGE);
|
|
3394
3418
|
}
|
|
3395
|
-
if (now() - announcedAt >= STILL_WAITING_MS) {
|
|
3419
|
+
if (now() - announcedAt >= (deps.stillWaitingMs ?? STILL_WAITING_MS)) {
|
|
3396
3420
|
announcedAt = now();
|
|
3397
|
-
write(
|
|
3398
|
-
`);
|
|
3421
|
+
write(stillWaitingLine(announcedAt - startedAt, started));
|
|
3399
3422
|
}
|
|
3400
3423
|
await sleep(interval);
|
|
3401
3424
|
}
|
|
3402
3425
|
}
|
|
3426
|
+
function stillWaitingLine(elapsedMs, started) {
|
|
3427
|
+
return `[halfcycle] Still waiting for the browser (${Math.round(elapsedMs / 1e3)}s). Code: ${started.userCode}
|
|
3428
|
+
`;
|
|
3429
|
+
}
|
|
3403
3430
|
var LOOPBACK_TIMEOUT_MESSAGE = "Sign-in timed out: the browser never came back to this terminal. Nothing was created. Run the command again \u2014 or, if this machine cannot hand the browser back (a remote session, a container), run it with HALFCYCLE_NO_BROWSER=1 to use the code-based sign-in instead.";
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
const
|
|
3407
|
-
const url =
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3431
|
+
async function pollBesideLoopback(serviceUrl, started, wake, deps) {
|
|
3432
|
+
const sleep = deps.sleep;
|
|
3433
|
+
const now = deps.now ?? Date.now;
|
|
3434
|
+
const url = tokenUrlFor(serviceUrl);
|
|
3435
|
+
const deadline = localDeadline(started);
|
|
3436
|
+
let arrived = false;
|
|
3437
|
+
let spent = false;
|
|
3438
|
+
let interrupt = null;
|
|
3439
|
+
void wake.then(() => {
|
|
3440
|
+
arrived = true;
|
|
3441
|
+
if (interrupt !== null)
|
|
3442
|
+
interrupt();
|
|
3443
|
+
});
|
|
3444
|
+
for (; ; ) {
|
|
3445
|
+
const answer = await askControlOnce(url, started);
|
|
3446
|
+
if (answer !== null) {
|
|
3447
|
+
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3448
|
+
return answer;
|
|
3449
|
+
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3450
|
+
throw new SignInRefused(answer.status, answer.message);
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
if (now() >= deadline) {
|
|
3454
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
|
|
3423
3455
|
}
|
|
3424
|
-
|
|
3425
|
-
|
|
3456
|
+
if (arrived && !spent) {
|
|
3457
|
+
spent = true;
|
|
3426
3458
|
continue;
|
|
3427
|
-
const answer = parsed.data;
|
|
3428
|
-
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3429
|
-
return answer;
|
|
3430
|
-
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3431
|
-
throw new SignInRefused(answer.status, answer.message);
|
|
3432
3459
|
}
|
|
3460
|
+
if (sleep === void 0) {
|
|
3461
|
+
await new Promise((resolve4) => {
|
|
3462
|
+
const timer = setTimeout(resolve4, started.pollIntervalMs);
|
|
3463
|
+
interrupt = () => {
|
|
3464
|
+
clearTimeout(timer);
|
|
3465
|
+
resolve4();
|
|
3466
|
+
};
|
|
3467
|
+
});
|
|
3468
|
+
} else {
|
|
3469
|
+
await new Promise((resolve4) => {
|
|
3470
|
+
interrupt = resolve4;
|
|
3471
|
+
void sleep(started.pollIntervalMs).then(resolve4);
|
|
3472
|
+
});
|
|
3473
|
+
}
|
|
3474
|
+
interrupt = null;
|
|
3475
|
+
if (arrived)
|
|
3476
|
+
spent = true;
|
|
3433
3477
|
}
|
|
3434
|
-
throw new SignInRefused(SIGN_IN_TIMED_OUT, lastProblem);
|
|
3435
3478
|
}
|
|
3436
3479
|
async function signIn(serviceUrl, deps = {}) {
|
|
3437
3480
|
const write = deps.write ?? defaultWrite;
|
|
@@ -3456,24 +3499,16 @@ async function signIn(serviceUrl, deps = {}) {
|
|
|
3456
3499
|
const url = loopbackVerificationUrl(started.verificationUrl, bind.target);
|
|
3457
3500
|
const attempt = await openBrowser(url, env);
|
|
3458
3501
|
if (attempt.opened) {
|
|
3459
|
-
write(`[halfcycle] Opened your browser to finish signing in. This terminal is waiting.
|
|
3502
|
+
write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. This terminal is waiting.
|
|
3460
3503
|
`);
|
|
3461
|
-
const
|
|
3462
|
-
const
|
|
3463
|
-
timeoutMs:
|
|
3464
|
-
onStillWaiting: (elapsed) => (
|
|
3465
|
-
|
|
3466
|
-
// because a human is comparing it; on this path there is nothing to
|
|
3467
|
-
// compare and criterion 1 is that none is displayed AT ALL.
|
|
3468
|
-
write(`[halfcycle] Still waiting for the browser (${Math.round(elapsed / 1e3)}s).
|
|
3469
|
-
`)
|
|
3470
|
-
)
|
|
3504
|
+
const startedAt = now();
|
|
3505
|
+
const redirect = bind.waitForCallback({
|
|
3506
|
+
timeoutMs: localDeadline(started) - startedAt,
|
|
3507
|
+
onStillWaiting: (elapsed) => write(stillWaitingLine(elapsed, started)),
|
|
3508
|
+
stillWaitingMs: deps.stillWaitingMs ?? STILL_WAITING_MS
|
|
3471
3509
|
});
|
|
3510
|
+
const approved = await pollBesideLoopback(serviceUrl, started, redirect.then(() => void 0), deps);
|
|
3472
3511
|
await closeListener();
|
|
3473
|
-
if (callback === null) {
|
|
3474
|
-
throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
|
|
3475
|
-
}
|
|
3476
|
-
const approved = await collectDecidedCredential(serviceUrl, started, deps);
|
|
3477
3512
|
write(`[halfcycle] Signed in as account ${approved.accountId} \u2014 continuing.
|
|
3478
3513
|
`);
|
|
3479
3514
|
return {
|
|
@@ -3578,7 +3613,7 @@ function planeRefusal(detail) {
|
|
|
3578
3613
|
reason: typeof candidate.reason === "string" ? candidate.reason : void 0
|
|
3579
3614
|
};
|
|
3580
3615
|
}
|
|
3581
|
-
var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements
|
|
3616
|
+
var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
|
|
3582
3617
|
async function createEngagement(baseUrl, name, credential, derivedName) {
|
|
3583
3618
|
return requestEngagementValues(
|
|
3584
3619
|
`${baseUrl.replace(/\/+$/, "")}/engagements`,
|
|
@@ -3658,12 +3693,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
3658
3693
|
}).join(", ");
|
|
3659
3694
|
throw new Error(`[bundle install] ${shape.action} response from ${url} is not the credential response this CLI is built to read (${fields}). ${shape.nothingHappened}. Either that service is a different version from this installer, or ${url} is not a Halfcycle service.`);
|
|
3660
3695
|
}
|
|
3696
|
+
const consoleUrl = typeof body.consoleUrl === "string" && body.consoleUrl.trim() !== "" ? body.consoleUrl.trim() : void 0;
|
|
3697
|
+
const methodVersion = typeof body.methodVersion === "string" && body.methodVersion.trim() !== "" ? body.methodVersion.trim() : void 0;
|
|
3661
3698
|
return {
|
|
3662
3699
|
engagementId: body.engagementId,
|
|
3663
3700
|
sessionToken: body.sessionToken,
|
|
3664
3701
|
mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
|
|
3665
3702
|
guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
|
|
3666
|
-
...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
|
|
3703
|
+
...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {},
|
|
3704
|
+
...consoleUrl !== void 0 ? { consoleUrl } : {},
|
|
3705
|
+
...methodVersion !== void 0 ? { methodVersion } : {}
|
|
3667
3706
|
};
|
|
3668
3707
|
}
|
|
3669
3708
|
|
|
@@ -4274,37 +4313,6 @@ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps =
|
|
|
4274
4313
|
}
|
|
4275
4314
|
}
|
|
4276
4315
|
|
|
4277
|
-
// dist/mint-board-code.js
|
|
4278
|
-
async function mintBoardEnterCode(baseUrl, sessionToken) {
|
|
4279
|
-
const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
|
|
4280
|
-
let res;
|
|
4281
|
-
try {
|
|
4282
|
-
res = await fetch(url, {
|
|
4283
|
-
method: "POST",
|
|
4284
|
-
headers: {
|
|
4285
|
-
"content-type": "application/json",
|
|
4286
|
-
authorization: `Bearer ${sessionToken}`
|
|
4287
|
-
},
|
|
4288
|
-
body: "{}"
|
|
4289
|
-
});
|
|
4290
|
-
} catch (err) {
|
|
4291
|
-
throw new Error(`[bundle install] Could not reach the Halfcycle board mint endpoint at ${url}: ${err instanceof Error ? err.message : String(err)}.`);
|
|
4292
|
-
}
|
|
4293
|
-
if (!res.ok) {
|
|
4294
|
-
throw new Error(`[bundle install] Board code mint failed: ${url} returned ${res.status}.`);
|
|
4295
|
-
}
|
|
4296
|
-
const body = await res.json().catch(() => null);
|
|
4297
|
-
if (!body || typeof body.enterCode !== "string" || typeof body.engagementId !== "string" || typeof body.boardUrl !== "string" || typeof body.expiresAt !== "string") {
|
|
4298
|
-
throw new Error(`[bundle install] Board code mint response from ${url} did not carry {enterCode, engagementId, boardUrl, expiresAt}.`);
|
|
4299
|
-
}
|
|
4300
|
-
return {
|
|
4301
|
-
enterCode: body.enterCode,
|
|
4302
|
-
engagementId: body.engagementId,
|
|
4303
|
-
boardUrl: body.boardUrl,
|
|
4304
|
-
expiresAt: body.expiresAt
|
|
4305
|
-
};
|
|
4306
|
-
}
|
|
4307
|
-
|
|
4308
4316
|
// dist/probe-mcp.js
|
|
4309
4317
|
var PROBE_TIMEOUT_MS = 5e3;
|
|
4310
4318
|
async function probeMcpOrigin(mcpUrl, token) {
|
|
@@ -4386,20 +4394,127 @@ import { basename as basename2, join as join6, resolve as resolve2, relative as
|
|
|
4386
4394
|
|
|
4387
4395
|
// dist/build-record/render-v2.js
|
|
4388
4396
|
var HIDDEN = /[\p{Cc}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
|
|
4389
|
-
var
|
|
4397
|
+
var SPACE = new RegExp("\\p{Zs}", "u");
|
|
4398
|
+
var WORD = /[\p{L}\p{N}]/u;
|
|
4399
|
+
var ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/;
|
|
4400
|
+
var CHARACTER_REFERENCE = /^#?[A-Za-z0-9]+;/;
|
|
4401
|
+
var PAIRED = ["*", "_", "~", "$"];
|
|
4402
|
+
function codeSpanMask(cps) {
|
|
4403
|
+
const mask = cps.map(() => false);
|
|
4404
|
+
const runs = [];
|
|
4405
|
+
for (let i = 0; i < cps.length; ) {
|
|
4406
|
+
if (cps[i] !== "`") {
|
|
4407
|
+
i++;
|
|
4408
|
+
continue;
|
|
4409
|
+
}
|
|
4410
|
+
let j = i;
|
|
4411
|
+
while (j < cps.length && cps[j] === "`")
|
|
4412
|
+
j++;
|
|
4413
|
+
runs.push({ start: i, length: j - i });
|
|
4414
|
+
i = j;
|
|
4415
|
+
}
|
|
4416
|
+
for (let k = 0; k < runs.length; ) {
|
|
4417
|
+
const open = runs[k];
|
|
4418
|
+
let m = k + 1;
|
|
4419
|
+
while (m < runs.length && runs[m].length !== open.length)
|
|
4420
|
+
m++;
|
|
4421
|
+
if (m === runs.length) {
|
|
4422
|
+
k++;
|
|
4423
|
+
continue;
|
|
4424
|
+
}
|
|
4425
|
+
const close = runs[m];
|
|
4426
|
+
for (let i = open.start; i < close.start + close.length; i++)
|
|
4427
|
+
mask[i] = true;
|
|
4428
|
+
k = m + 1;
|
|
4429
|
+
}
|
|
4430
|
+
return mask;
|
|
4431
|
+
}
|
|
4432
|
+
function escapeMask(cps, inSpan) {
|
|
4433
|
+
const n = cps.length;
|
|
4434
|
+
const escape = cps.map(() => false);
|
|
4435
|
+
const isSpace = (i) => i >= 0 && i < n && SPACE.test(cps[i]) && !HIDDEN.test(cps[i]);
|
|
4436
|
+
const isWord = (i) => i >= 0 && i < n && WORD.test(cps[i]);
|
|
4437
|
+
const rest = (i) => cps.slice(i).join("");
|
|
4438
|
+
for (let i = 0; i < n; i++) {
|
|
4439
|
+
if (inSpan[i])
|
|
4440
|
+
continue;
|
|
4441
|
+
const ch = cps[i];
|
|
4442
|
+
const next = i + 1 < n ? cps[i + 1] : null;
|
|
4443
|
+
switch (ch) {
|
|
4444
|
+
case "\\":
|
|
4445
|
+
escape[i] = next === null || ASCII_PUNCTUATION.test(next);
|
|
4446
|
+
break;
|
|
4447
|
+
case "<":
|
|
4448
|
+
escape[i] = next !== null && !isSpace(i + 1) && rest(i + 1).includes(">");
|
|
4449
|
+
break;
|
|
4450
|
+
case "&":
|
|
4451
|
+
escape[i] = CHARACTER_REFERENCE.test(rest(i + 1));
|
|
4452
|
+
break;
|
|
4453
|
+
case "]":
|
|
4454
|
+
escape[i] = next === "(";
|
|
4455
|
+
break;
|
|
4456
|
+
case "[":
|
|
4457
|
+
case "{":
|
|
4458
|
+
escape[i] = (next === "+" || next === "-") && (rest(i + 2).includes(`${next}]`) || rest(i + 2).includes(`${next}}`));
|
|
4459
|
+
break;
|
|
4460
|
+
default:
|
|
4461
|
+
break;
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
for (let i = 0; i < n; ) {
|
|
4465
|
+
if (inSpan[i] || cps[i] !== "#") {
|
|
4466
|
+
i++;
|
|
4467
|
+
continue;
|
|
4468
|
+
}
|
|
4469
|
+
let j = i;
|
|
4470
|
+
while (j < n && cps[j] === "#" && !inSpan[j])
|
|
4471
|
+
j++;
|
|
4472
|
+
let k = j;
|
|
4473
|
+
while (k < n && isSpace(k))
|
|
4474
|
+
k++;
|
|
4475
|
+
if (k === n && (i === 0 || isSpace(i - 1)))
|
|
4476
|
+
for (let m = i; m < j; m++)
|
|
4477
|
+
escape[m] = true;
|
|
4478
|
+
i = j;
|
|
4479
|
+
}
|
|
4480
|
+
for (const c of PAIRED) {
|
|
4481
|
+
const live = [];
|
|
4482
|
+
for (let i = 0; i < n; ) {
|
|
4483
|
+
if (inSpan[i] || cps[i] !== c) {
|
|
4484
|
+
i++;
|
|
4485
|
+
continue;
|
|
4486
|
+
}
|
|
4487
|
+
let j = i;
|
|
4488
|
+
while (j < n && cps[j] === c && !inSpan[j])
|
|
4489
|
+
j++;
|
|
4490
|
+
const inert = c !== "$" && (isSpace(i - 1) && isSpace(j) || c === "_" && isWord(i - 1) && isWord(j));
|
|
4491
|
+
if (!inert)
|
|
4492
|
+
live.push({ start: i, end: j });
|
|
4493
|
+
i = j;
|
|
4494
|
+
}
|
|
4495
|
+
if (live.length < 2)
|
|
4496
|
+
continue;
|
|
4497
|
+
for (const run of live)
|
|
4498
|
+
for (let m = run.start; m < run.end; m++)
|
|
4499
|
+
escape[m] = true;
|
|
4500
|
+
}
|
|
4501
|
+
return escape;
|
|
4502
|
+
}
|
|
4390
4503
|
function printRecordText(text) {
|
|
4391
4504
|
if (text === "")
|
|
4392
4505
|
return "(blank)";
|
|
4506
|
+
const cps = [...text];
|
|
4507
|
+
const escape = escapeMask(cps, codeSpanMask(cps));
|
|
4393
4508
|
let out = "";
|
|
4394
|
-
|
|
4509
|
+
cps.forEach((ch, i) => {
|
|
4395
4510
|
if (HIDDEN.test(ch)) {
|
|
4396
4511
|
out += `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
4397
|
-
} else if (
|
|
4512
|
+
} else if (escape[i]) {
|
|
4398
4513
|
out += `\\${ch}`;
|
|
4399
4514
|
} else {
|
|
4400
4515
|
out += ch;
|
|
4401
4516
|
}
|
|
4402
|
-
}
|
|
4517
|
+
});
|
|
4403
4518
|
return out;
|
|
4404
4519
|
}
|
|
4405
4520
|
function makePrinter() {
|
|
@@ -4489,12 +4604,18 @@ function headline(observed, attested, checks) {
|
|
|
4489
4604
|
if (problemsReported) {
|
|
4490
4605
|
return "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk.";
|
|
4491
4606
|
}
|
|
4492
|
-
return checks === "not-observed" ? "**Closed with nothing outstanding, but no
|
|
4607
|
+
return checks === "not-observed" ? "**Closed with nothing outstanding, but no check on record.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met, but Halfcycle has no record of any check running during this phase, so this is not a clean result." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
|
|
4493
4608
|
case "unfinished-steps":
|
|
4494
4609
|
case "clean-over-findings":
|
|
4495
|
-
|
|
4610
|
+
if (!met) {
|
|
4611
|
+
return "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase chose to override it.";
|
|
4612
|
+
}
|
|
4613
|
+
return problemsReported ? "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase closed it as it stood, reporting problems found in its acceptance walk." : "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase stated its finish conditions were met anyway.";
|
|
4496
4614
|
case "not-recorded":
|
|
4497
|
-
|
|
4615
|
+
if (!met) {
|
|
4616
|
+
return "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase chose to override.";
|
|
4617
|
+
}
|
|
4618
|
+
return problemsReported ? "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase reported problems found in its acceptance walk." : "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase stated its finish conditions were met.";
|
|
4498
4619
|
}
|
|
4499
4620
|
}
|
|
4500
4621
|
function closedAtText(closedAt, p) {
|
|
@@ -4513,30 +4634,38 @@ function signedInAs(closedBy, p) {
|
|
|
4513
4634
|
return "not recorded for this close";
|
|
4514
4635
|
}
|
|
4515
4636
|
}
|
|
4637
|
+
var OUTSTANDING_LABEL = "- Outstanding work in Halfcycle's own records of this phase (the method's steps, and problems recorded against the phase): ";
|
|
4516
4638
|
function outstanding(crossed, p) {
|
|
4517
4639
|
switch (crossed.kind) {
|
|
4518
4640
|
case "none":
|
|
4519
|
-
return [
|
|
4641
|
+
return [`${OUTSTANDING_LABEL}none.`];
|
|
4520
4642
|
case "not-recorded":
|
|
4521
|
-
return [
|
|
4643
|
+
return [`${OUTSTANDING_LABEL}not recorded for this close.`];
|
|
4522
4644
|
case "clean-over-findings":
|
|
4523
|
-
return [
|
|
4524
|
-
`- Outstanding work Halfcycle could see: ${counted(crossed.findings, "problem", "problems")} recorded against this phase.`
|
|
4525
|
-
];
|
|
4645
|
+
return [`${OUTSTANDING_LABEL}${counted(crossed.findings, "problem", "problems")} recorded against this phase.`];
|
|
4526
4646
|
case "unfinished-steps":
|
|
4527
4647
|
return [
|
|
4528
|
-
|
|
4529
|
-
...crossed.steps.map((step) => ` - Step: ${p.text(step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
|
|
4648
|
+
`${OUTSTANDING_LABEL}${counted(crossed.steps.length, "step", "steps")} of the method left unfinished. ${shownBy(crossed.steps)}`,
|
|
4649
|
+
...crossed.steps.map((step) => ` - Step: ${p.text(step.name ?? step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
|
|
4530
4650
|
];
|
|
4531
4651
|
}
|
|
4532
4652
|
}
|
|
4533
|
-
function
|
|
4653
|
+
function shownBy(steps) {
|
|
4654
|
+
const named = steps.filter((s) => s.name !== void 0).length;
|
|
4655
|
+
if (named === 0)
|
|
4656
|
+
return "Each is shown by the method's own label for it:";
|
|
4657
|
+
if (named === steps.length)
|
|
4658
|
+
return "Each is shown by the method's own name for it:";
|
|
4659
|
+
return "Each is shown by the method's own name for it, or by its label where the record holds no name:";
|
|
4660
|
+
}
|
|
4661
|
+
function machines(coverage, checks) {
|
|
4534
4662
|
const label = "- Computers signed in to Halfcycle for this project when the phase closed: ";
|
|
4535
4663
|
if (coverage === null)
|
|
4536
4664
|
return `${label}not recorded.`;
|
|
4537
4665
|
if (coverage.enrolled === 0)
|
|
4538
4666
|
return `${label}none.`;
|
|
4539
|
-
|
|
4667
|
+
const line = `${label}${String(coverage.enrolled)}. Of these, ${String(coverage.conformant)} had reported Halfcycle's checks as fully set up.`;
|
|
4668
|
+
return checks === "not-observed" && coverage.conformant > 0 ? `${line} Set up is not the same as running: Halfcycle has no record of any check running during this phase.` : line;
|
|
4540
4669
|
}
|
|
4541
4670
|
function walkLines(walk, p) {
|
|
4542
4671
|
const label = "> - Acceptance walk (the finished work tried as a user would, by someone other than its builder): ";
|
|
@@ -4556,9 +4685,15 @@ function walkLines(walk, p) {
|
|
|
4556
4685
|
function statedOrNot(value, p) {
|
|
4557
4686
|
return value === null ? "Not stated by the client." : p.text(value);
|
|
4558
4687
|
}
|
|
4688
|
+
function decisionLine(attested) {
|
|
4689
|
+
if (attested.decision === "override") {
|
|
4690
|
+
return "> - Decision: close the phase by override, without stating its finish conditions were met.";
|
|
4691
|
+
}
|
|
4692
|
+
return attested.walk !== null && attested.walk.verdict === "defects" ? "> - Decision: close the phase as it stood, with the problems its acceptance walk found." : "> - Decision: the phase's agreed finish conditions were met.";
|
|
4693
|
+
}
|
|
4559
4694
|
function renderStated(attested, p) {
|
|
4560
4695
|
const lines = [
|
|
4561
|
-
attested
|
|
4696
|
+
decisionLine(attested),
|
|
4562
4697
|
`> - Name given: ${attested.actor === null ? "removed with the account that made this close" : p.text(attested.actor)}`,
|
|
4563
4698
|
`> - Reason given: ${attested.reason === null ? "none." : p.text(attested.reason)}`,
|
|
4564
4699
|
...walkLines(attested.walk, p),
|
|
@@ -4586,7 +4721,7 @@ function renderClose(heading, observed, attested, checks, p) {
|
|
|
4586
4721
|
`- Closed at: ${closedAtText(observed.closedAt, p)}`,
|
|
4587
4722
|
`- Signed in as: ${signedInAs(observed.closedBy, p)}`,
|
|
4588
4723
|
...outstanding(observed.crossed, p),
|
|
4589
|
-
machines(observed.seatCoverage)
|
|
4724
|
+
machines(observed.seatCoverage, checks)
|
|
4590
4725
|
].join("\n"),
|
|
4591
4726
|
STATED_LABEL,
|
|
4592
4727
|
renderStated(attested, p)
|
|
@@ -4847,8 +4982,7 @@ async function obtainBuildRecord(credential, repoRoot, phase, io = NODE_RECORD_P
|
|
|
4847
4982
|
};
|
|
4848
4983
|
}
|
|
4849
4984
|
function refetchCommand(phase) {
|
|
4850
|
-
|
|
4851
|
-
return `npx halfcycle@latest build-record ${quoted}`;
|
|
4985
|
+
return `npx halfcycle@latest build-record ${quoteForShell(phase)}`;
|
|
4852
4986
|
}
|
|
4853
4987
|
|
|
4854
4988
|
// dist/open-phase.js
|
|
@@ -4857,10 +4991,12 @@ import { join as join7 } from "node:path";
|
|
|
4857
4991
|
var OpenPhaseRefused = class extends Error {
|
|
4858
4992
|
status;
|
|
4859
4993
|
finding;
|
|
4860
|
-
|
|
4994
|
+
previousPhase;
|
|
4995
|
+
constructor(status, message, finding, previousPhase = null) {
|
|
4861
4996
|
super(message);
|
|
4862
4997
|
this.status = status;
|
|
4863
4998
|
this.finding = finding;
|
|
4999
|
+
this.previousPhase = previousPhase;
|
|
4864
5000
|
this.name = "OpenPhaseRefused";
|
|
4865
5001
|
}
|
|
4866
5002
|
};
|
|
@@ -4939,7 +5075,8 @@ async function openPhase(credential, phase, entry, home) {
|
|
|
4939
5075
|
const body2 = await res.json().catch(() => null);
|
|
4940
5076
|
const message = typeof body2?.message === "string" ? body2.message : `${url} returned ${res.status}.`;
|
|
4941
5077
|
const finding = typeof body2?.finding === "string" ? body2.finding : null;
|
|
4942
|
-
|
|
5078
|
+
const previousPhase = typeof body2?.previousPhase === "string" ? body2.previousPhase : null;
|
|
5079
|
+
throw new OpenPhaseRefused(res.status, message, finding, previousPhase);
|
|
4943
5080
|
}
|
|
4944
5081
|
const body = await res.json().catch(() => null);
|
|
4945
5082
|
if (!body || !(typeof body.phase === "string" || body.phase === null) || !(typeof body.previousPhase === "string" || body.previousPhase === null) || typeof body.decision !== "object" || body.decision === null || typeof body.decision.decisionId !== "string") {
|
|
@@ -5165,7 +5302,7 @@ function requirementMessage(verb, flag) {
|
|
|
5165
5302
|
function runnableInvocation(verb, phase) {
|
|
5166
5303
|
const contract = contractFor(verb);
|
|
5167
5304
|
const flags = (contract?.required ?? []).map((req) => `${req.flag} ${PLACEHOLDERS[req.flag] ?? '"\u2026"'}`).join(" ");
|
|
5168
|
-
return `halfcycle ${verb} ${phase}${flags ? ` ${flags}` : ""}`;
|
|
5305
|
+
return `halfcycle ${verb} ${quoteForShell(phase)}${flags ? ` ${flags}` : ""}`;
|
|
5169
5306
|
}
|
|
5170
5307
|
var PLACEHOLDERS = {
|
|
5171
5308
|
"--verdict": "<clean|defects>",
|
|
@@ -6092,7 +6229,12 @@ ${USAGE}`);
|
|
|
6092
6229
|
// T-10 — optional; absent on a plane that has not configured
|
|
6093
6230
|
// CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
|
|
6094
6231
|
// an absent value, never `undefined` verbatim.
|
|
6095
|
-
controlTelemetryUrl: joined.controlTelemetryUrl
|
|
6232
|
+
controlTelemetryUrl: joined.controlTelemetryUrl,
|
|
6233
|
+
// T-01 (Connect Phase 4 follow-ups) — optional; absent on a control
|
|
6234
|
+
// deployment that predates it. The join route's own stamped pin,
|
|
6235
|
+
// which can be older than this deployment's current default.
|
|
6236
|
+
consoleUrl: joined.consoleUrl,
|
|
6237
|
+
methodVersion: joined.methodVersion
|
|
6096
6238
|
};
|
|
6097
6239
|
foundLine = `this repo is pinned to one of your projects \u2014 joining it as you (${shortId(joined.engagementId)})`;
|
|
6098
6240
|
}
|
|
@@ -6116,7 +6258,12 @@ ${USAGE}`);
|
|
|
6116
6258
|
// T-10 — the FOURTH origin, optional. Absent on a plane that has not
|
|
6117
6259
|
// configured CONTROL_TELEMETRY_URL yet; the runner's own telemetry
|
|
6118
6260
|
// emission is fail-open on that absence.
|
|
6119
|
-
controlTelemetryUrl: created.controlTelemetryUrl
|
|
6261
|
+
controlTelemetryUrl: created.controlTelemetryUrl,
|
|
6262
|
+
// T-01 (Connect Phase 4 follow-ups) — optional; absent on a control
|
|
6263
|
+
// deployment that predates it. The create route's own deployment
|
|
6264
|
+
// default at the moment this engagement was stamped.
|
|
6265
|
+
consoleUrl: created.consoleUrl,
|
|
6266
|
+
methodVersion: created.methodVersion
|
|
6120
6267
|
};
|
|
6121
6268
|
foundLine = `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
|
|
6122
6269
|
if (verbose) {
|
|
@@ -6209,22 +6356,23 @@ ${USAGE}`);
|
|
|
6209
6356
|
if (result.notShared)
|
|
6210
6357
|
process.stdout.write(`${renderNotShared(result.notShared)}
|
|
6211
6358
|
`);
|
|
6359
|
+
if (credential) {
|
|
6360
|
+
process.stdout.write("[halfcycle] Commit the files Halfcycle set up here, so every copy of this project runs the same checks.\n");
|
|
6361
|
+
}
|
|
6212
6362
|
if (credential) {
|
|
6213
6363
|
process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
|
|
6214
6364
|
`);
|
|
6215
|
-
process.stdout.write(`[halfcycle] Claude Code will
|
|
6365
|
+
process.stdout.write(`[halfcycle] Claude Code will then ask whether to use a new MCP server. The cursor starts on "Continue without using this MCP server" \u2014 choose to use it instead; without it Halfcycle will not connect.
|
|
6216
6366
|
`);
|
|
6217
6367
|
}
|
|
6218
|
-
if (credential) {
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
process.stdout.write(`[halfcycle] Your project's page \u2014 how it is going, the agents working on it, and your other projects (first visit): ${minted.boardUrl}
|
|
6368
|
+
if (credential?.consoleUrl !== void 0) {
|
|
6369
|
+
const colour = decideColour(currentEnvironment(quiet));
|
|
6370
|
+
process.stdout.write(`${ink("key", `[halfcycle] Your project's console: ${credential.consoleUrl}`, colour)}
|
|
6222
6371
|
`);
|
|
6223
|
-
|
|
6372
|
+
}
|
|
6373
|
+
if (credential?.methodVersion !== void 0) {
|
|
6374
|
+
process.stdout.write(`[halfcycle] Method version: ${credential.methodVersion}
|
|
6224
6375
|
`);
|
|
6225
|
-
} catch {
|
|
6226
|
-
process.stdout.write("[halfcycle] Your project's page: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
|
|
6227
|
-
}
|
|
6228
6376
|
}
|
|
6229
6377
|
process.exit(0);
|
|
6230
6378
|
} catch (err) {
|
|
@@ -6271,7 +6419,9 @@ ${USAGE}`);
|
|
|
6271
6419
|
return;
|
|
6272
6420
|
}
|
|
6273
6421
|
try {
|
|
6274
|
-
const { drifted, installed, current } = checkDrift(targetRepo2);
|
|
6422
|
+
const { drifted, installed, current, methodVersion } = checkDrift(targetRepo2);
|
|
6423
|
+
process.stdout.write(`[halfcycle-bundle] Method version: ${methodVersion ?? "not recorded"}
|
|
6424
|
+
`);
|
|
6275
6425
|
if (drifted) {
|
|
6276
6426
|
process.stdout.write(`[halfcycle-bundle] DRIFT DETECTED: installed=${installed ?? "none"} current=${current}
|
|
6277
6427
|
[halfcycle-bundle] Re-run "halfcycle-bundle install ${targetRepo2}" to sync.
|
|
@@ -6343,8 +6493,13 @@ ${USAGE}`);
|
|
|
6343
6493
|
const opened = await openPhase(credential, toProjectScope ? null : phaseArg, entry);
|
|
6344
6494
|
const what = opened.phase === null ? "project scope" : `phase "${opened.phase}"`;
|
|
6345
6495
|
const from = opened.previousPhase === null ? "project scope" : `phase "${opened.previousPhase}"`;
|
|
6346
|
-
|
|
6496
|
+
if (opened.previousPhase === opened.phase) {
|
|
6497
|
+
process.stdout.write(opened.phase === null ? "[halfcycle] Already at project scope \u2014 nothing moved.\n" : `[halfcycle] Phase "${opened.phase}" is already open \u2014 nothing moved.
|
|
6498
|
+
`);
|
|
6499
|
+
} else {
|
|
6500
|
+
process.stdout.write(`[halfcycle] Moved from ${from} to ${what}.
|
|
6347
6501
|
`);
|
|
6502
|
+
}
|
|
6348
6503
|
process.stdout.write(`[halfcycle] Recorded as ${opened.decision.decision} by ${opened.decision.actor} at ${opened.decision.decidedAt}.
|
|
6349
6504
|
`);
|
|
6350
6505
|
if (opened.decision.blockingFinding) {
|
|
@@ -6361,8 +6516,7 @@ halfcycle open-phase: until ${opened.stampFailure.path} can be written, guard ev
|
|
|
6361
6516
|
process.exit(0);
|
|
6362
6517
|
} catch (err) {
|
|
6363
6518
|
if (err instanceof InvalidPhaseIdentityError) {
|
|
6364
|
-
process.stderr.write(`halfcycle open-phase: ${err.
|
|
6365
|
-
halfcycle open-phase: a phase name has to work as a single folder name on this machine \u2014 that is what this machine files its guard evaluations under. Nothing was opened.
|
|
6519
|
+
process.stderr.write(`halfcycle open-phase: '${String(err.identity)}' cannot be used as a phase name \u2014 it has to work as a single folder name on this machine. Nothing was opened.
|
|
6366
6520
|
`);
|
|
6367
6521
|
process.exit(2);
|
|
6368
6522
|
return;
|
|
@@ -6375,8 +6529,7 @@ halfcycle open-phase: a phase name has to work as a single folder name on this m
|
|
|
6375
6529
|
`);
|
|
6376
6530
|
}
|
|
6377
6531
|
if (err.status === 409) {
|
|
6378
|
-
|
|
6379
|
-
process.stderr.write(`halfcycle open-phase: close the phase first \u2014 \`${runnableInvocation("close-phase", stuck ?? "<phase>")}\` (add --finding "\u2026" once per finding when the verdict is defects) \u2014 or re-run with --override --reason "\u2026", which is recorded, not waived.
|
|
6532
|
+
process.stderr.write(`halfcycle open-phase: close the phase first \u2014 \`${runnableInvocation("close-phase", err.previousPhase ?? "<phase>")}\` (add --finding "\u2026" once per finding when the verdict is defects) \u2014 or re-run with --override --reason "\u2026", which is recorded, not waived.
|
|
6380
6533
|
`);
|
|
6381
6534
|
}
|
|
6382
6535
|
process.exit(1);
|
|
@@ -6487,17 +6640,22 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
|
|
|
6487
6640
|
return;
|
|
6488
6641
|
}
|
|
6489
6642
|
if (err instanceof ClosePhaseRefused) {
|
|
6643
|
+
if (err.finding) {
|
|
6644
|
+
process.stderr.write(`halfcycle close-phase: refused (${err.status}).
|
|
6645
|
+
`);
|
|
6646
|
+
process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${plainOutstandingFinding(err.finding)}
|
|
6647
|
+
`);
|
|
6648
|
+
process.stderr.write(`halfcycle close-phase: close it anyway with \`halfcycle close-phase ${quoteForShell(phaseArg)} --verdict ${verdict} --actor ${quoteForShell(closeActor)} --override --reason "\u2026"\` \u2014 recorded, not waived.
|
|
6649
|
+
`);
|
|
6650
|
+
process.exit(1);
|
|
6651
|
+
return;
|
|
6652
|
+
}
|
|
6490
6653
|
process.stderr.write(`halfcycle close-phase: refused (${err.status}). ${err.message}
|
|
6491
6654
|
`);
|
|
6492
6655
|
if (err.status === 400) {
|
|
6493
6656
|
process.stderr.write("halfcycle close-phase: Nothing was closed.\n");
|
|
6494
6657
|
}
|
|
6495
|
-
if (err.
|
|
6496
|
-
process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${err.finding}
|
|
6497
|
-
`);
|
|
6498
|
-
process.stderr.write(`halfcycle close-phase: close it anyway with \`halfcycle close-phase ${phaseArg} --verdict ${verdict} --actor "${closeActor}" --override --reason "\u2026"\` \u2014 recorded, not waived.
|
|
6499
|
-
`);
|
|
6500
|
-
} else if (err.code === "PhaseAlreadyClosed") {
|
|
6658
|
+
if (err.code === "PhaseAlreadyClosed") {
|
|
6501
6659
|
process.stderr.write("halfcycle close-phase: to close a phase again, open it again first.\nhalfcycle close-phase: To get a closed phase's Build Record: npx halfcycle@latest build-record <the phase that closed>\n");
|
|
6502
6660
|
}
|
|
6503
6661
|
process.exit(1);
|
|
@@ -6536,6 +6694,9 @@ function readStatementFlags(argv) {
|
|
|
6536
6694
|
}
|
|
6537
6695
|
return { thesis, outcome, deviations };
|
|
6538
6696
|
}
|
|
6697
|
+
function plainOutstandingFinding(finding) {
|
|
6698
|
+
return finding.replace(/\(attempted-failed\)/g, "(attempted, and did not finish)");
|
|
6699
|
+
}
|
|
6539
6700
|
function writtenLines(written) {
|
|
6540
6701
|
return `[halfcycle] Build Record written:
|
|
6541
6702
|
${written.mdPath}
|