halfcycle 0.3.36 → 0.3.37
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 +3 -1
- package/bin/bin.bundle.mjs +260 -81
- package/dist/bin.js +162 -138
- package/dist/bin.js.map +3 -3
- 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.js +40 -63
- package/dist/index.js.map +3 -3
- package/dist/install.d.ts +2 -8
- 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/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -230,6 +230,10 @@ 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
|
+
|
|
233
237
|
// ../events/dist/result.js
|
|
234
238
|
import { z as z2 } from "zod";
|
|
235
239
|
var firedGuardSchema = z2.object({
|
|
@@ -1220,7 +1224,19 @@ var ciTokenExchangeResponseSchema = z10.object({
|
|
|
1220
1224
|
expiresAt: utcIso8601,
|
|
1221
1225
|
engagementId: z10.string()
|
|
1222
1226
|
}).strict();
|
|
1227
|
+
var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
|
|
1228
|
+
var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
|
|
1229
|
+
var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
|
|
1223
1230
|
var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
|
|
1231
|
+
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.';
|
|
1232
|
+
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}.`;
|
|
1233
|
+
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.";
|
|
1234
|
+
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.";
|
|
1235
|
+
var PLACEHOLDER_SENTENCES = Object.freeze({
|
|
1236
|
+
[NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
|
|
1237
|
+
[NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
|
|
1238
|
+
[UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
|
|
1239
|
+
});
|
|
1224
1240
|
|
|
1225
1241
|
// ../events/dist/phase-record.js
|
|
1226
1242
|
import { z as z11 } from "zod";
|
|
@@ -2001,7 +2017,7 @@ var WRITE_ALLOWLIST = [
|
|
|
2001
2017
|
".halfcycle/bin",
|
|
2002
2018
|
"docs/build-records",
|
|
2003
2019
|
"test/fixtures/captured",
|
|
2004
|
-
|
|
2020
|
+
MCP_REGISTRATION_REL,
|
|
2005
2021
|
".gitattributes",
|
|
2006
2022
|
ZONE_B_DIR
|
|
2007
2023
|
];
|
|
@@ -2374,6 +2390,20 @@ exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
|
2374
2390
|
}
|
|
2375
2391
|
var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
|
|
2376
2392
|
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)"`;
|
|
2393
|
+
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)"`;
|
|
2394
|
+
function noNodeReplyShell() {
|
|
2395
|
+
const reply = JSON.stringify({
|
|
2396
|
+
systemMessage: NO_NODE_SENTENCE,
|
|
2397
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: NO_NODE_SENTENCE }
|
|
2398
|
+
});
|
|
2399
|
+
if (reply.includes("'")) {
|
|
2400
|
+
throw new Error("[bundle install] the no-Node notice cannot be single-quoted; refusing to write the session-start script.");
|
|
2401
|
+
}
|
|
2402
|
+
return `if ! command -v node >/dev/null 2>&1; then
|
|
2403
|
+
printf '%s\\n' '${reply}'
|
|
2404
|
+
exit 0
|
|
2405
|
+
fi`;
|
|
2406
|
+
}
|
|
2377
2407
|
function generateSessionStartMarker() {
|
|
2378
2408
|
return `#!/usr/bin/env bash
|
|
2379
2409
|
${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
@@ -2382,10 +2412,15 @@ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
|
2382
2412
|
# it began instead of only against the latest commit. The session id comes in on
|
|
2383
2413
|
# stdin with the hook input; without one, the older session-less name is written
|
|
2384
2414
|
# and the stop-time check compares against the latest commit as it used to.
|
|
2415
|
+
#
|
|
2416
|
+
# Without Node nothing Halfcycle installs here can run, so this says so once and
|
|
2417
|
+
# writes no marker.
|
|
2418
|
+
${noNodeReplyShell()}
|
|
2385
2419
|
INPUT="$(cat 2>/dev/null || true)"
|
|
2386
2420
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
2387
2421
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
2388
|
-
|
|
2422
|
+
${REPO_HASH_FROM_ROOT_SH}
|
|
2423
|
+
if [ -z "$HASH" ]; then exit 0; fi
|
|
2389
2424
|
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
2390
2425
|
MARKER_DIR="\${TMPDIR:-/tmp}"
|
|
2391
2426
|
MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
|
|
@@ -2395,64 +2430,6 @@ else
|
|
|
2395
2430
|
MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
|
|
2396
2431
|
fi
|
|
2397
2432
|
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
2433
|
exit 0
|
|
2457
2434
|
`;
|
|
2458
2435
|
}
|
|
@@ -2468,7 +2445,8 @@ INPUT="$(cat 2>/dev/null || true)"
|
|
|
2468
2445
|
|
|
2469
2446
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
2470
2447
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
2471
|
-
|
|
2448
|
+
${REPO_HASH_FROM_ROOT_SH}
|
|
2449
|
+
if [ -z "$HASH" ]; then exit 0; fi
|
|
2472
2450
|
|
|
2473
2451
|
# The session id arrives on stdin as part of the hook payload. Empty when absent.
|
|
2474
2452
|
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
@@ -2588,8 +2566,6 @@ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeade
|
|
|
2588
2566
|
}
|
|
2589
2567
|
return "removed";
|
|
2590
2568
|
}
|
|
2591
|
-
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
2592
|
-
var MCP_SERVER_KEY = "halfcycle";
|
|
2593
2569
|
var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
|
|
2594
2570
|
`const p=require('path'),f=require('fs'),c=require('child_process');`,
|
|
2595
2571
|
`let d=process.cwd();`,
|
|
@@ -3180,6 +3156,7 @@ async function bindLoopback(env = process.env, engagementId) {
|
|
|
3180
3156
|
const state = randomBytes(32).toString("hex");
|
|
3181
3157
|
let pending = null;
|
|
3182
3158
|
let deliver = null;
|
|
3159
|
+
let abandon = null;
|
|
3183
3160
|
const server = createServer(createCallbackHandler(state, (callback) => {
|
|
3184
3161
|
if (deliver)
|
|
3185
3162
|
deliver(callback);
|
|
@@ -3221,12 +3198,18 @@ async function bindLoopback(env = process.env, engagementId) {
|
|
|
3221
3198
|
clearInterval(ticker);
|
|
3222
3199
|
clearTimeout(timer);
|
|
3223
3200
|
deliver = null;
|
|
3201
|
+
abandon = null;
|
|
3224
3202
|
resolve4(value);
|
|
3225
3203
|
};
|
|
3226
3204
|
const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
|
|
3227
3205
|
deliver = finish;
|
|
3206
|
+
abandon = () => finish(null);
|
|
3228
3207
|
}),
|
|
3229
|
-
close: () =>
|
|
3208
|
+
close: () => {
|
|
3209
|
+
if (abandon !== null)
|
|
3210
|
+
abandon();
|
|
3211
|
+
return closeServer(server);
|
|
3212
|
+
}
|
|
3230
3213
|
};
|
|
3231
3214
|
}
|
|
3232
3215
|
function closeServer(server) {
|
|
@@ -3260,8 +3243,13 @@ function defaultSleep(ms) {
|
|
|
3260
3243
|
function browserOpenCommand(os, url) {
|
|
3261
3244
|
if (os === "darwin")
|
|
3262
3245
|
return { command: "open", args: [url] };
|
|
3263
|
-
if (os === "win32")
|
|
3264
|
-
return {
|
|
3246
|
+
if (os === "win32") {
|
|
3247
|
+
return {
|
|
3248
|
+
command: "cmd.exe",
|
|
3249
|
+
args: ["/d", "/s", "/c", `"start "" "${url}""`],
|
|
3250
|
+
windowsVerbatimArguments: true
|
|
3251
|
+
};
|
|
3252
|
+
}
|
|
3265
3253
|
return { command: "xdg-open", args: [url] };
|
|
3266
3254
|
}
|
|
3267
3255
|
function isOpenableUrl(url) {
|
|
@@ -3272,19 +3260,35 @@ function isOpenableUrl(url) {
|
|
|
3272
3260
|
return false;
|
|
3273
3261
|
}
|
|
3274
3262
|
}
|
|
3263
|
+
function hasWin32UnsafeOpenerChars(url) {
|
|
3264
|
+
if (url.includes('"') || url.includes("%"))
|
|
3265
|
+
return true;
|
|
3266
|
+
for (let i = 0; i < url.length; i++) {
|
|
3267
|
+
const code = url.charCodeAt(i);
|
|
3268
|
+
if (code <= 31 || code === 127)
|
|
3269
|
+
return true;
|
|
3270
|
+
}
|
|
3271
|
+
return false;
|
|
3272
|
+
}
|
|
3275
3273
|
var BROWSER_OPEN_GRACE_MS = 3e3;
|
|
3276
|
-
async function openBrowser(url, env = process.env) {
|
|
3274
|
+
async function openBrowser(url, env = process.env, os = platform3(), spawnImpl = spawn) {
|
|
3277
3275
|
if ((env["HALFCYCLE_NO_BROWSER"] ?? "").trim() !== "") {
|
|
3278
3276
|
return { opened: false, reason: "HALFCYCLE_NO_BROWSER is set in this environment" };
|
|
3279
3277
|
}
|
|
3280
3278
|
if (!isOpenableUrl(url)) {
|
|
3281
3279
|
return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
|
|
3282
3280
|
}
|
|
3283
|
-
|
|
3281
|
+
if (os === "win32" && hasWin32UnsafeOpenerChars(url)) {
|
|
3282
|
+
return {
|
|
3283
|
+
opened: false,
|
|
3284
|
+
reason: 'the sign-in address holds a " or %, or a control character, this opener will not pass to cmd.exe on Windows'
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
const { command, args: args2, windowsVerbatimArguments } = browserOpenCommand(os, url);
|
|
3284
3288
|
return new Promise((resolve4) => {
|
|
3285
3289
|
let child;
|
|
3286
3290
|
try {
|
|
3287
|
-
child =
|
|
3291
|
+
child = spawnImpl(command, args2, { stdio: "ignore", env, windowsVerbatimArguments });
|
|
3288
3292
|
} catch (err) {
|
|
3289
3293
|
resolve4({
|
|
3290
3294
|
opened: false,
|
|
@@ -3356,82 +3360,107 @@ function tryRefusal(body) {
|
|
|
3356
3360
|
}
|
|
3357
3361
|
return { status: candidate.status, message: candidate.message };
|
|
3358
3362
|
}
|
|
3363
|
+
async function askControlOnce(tokenUrl, started) {
|
|
3364
|
+
let body;
|
|
3365
|
+
try {
|
|
3366
|
+
const res = await fetch(tokenUrl, {
|
|
3367
|
+
method: "POST",
|
|
3368
|
+
headers: { "content-type": "application/json" },
|
|
3369
|
+
body: JSON.stringify({ deviceCode: started.deviceCode })
|
|
3370
|
+
});
|
|
3371
|
+
body = await res.json().catch(() => null);
|
|
3372
|
+
} catch {
|
|
3373
|
+
return null;
|
|
3374
|
+
}
|
|
3375
|
+
const parsed = devicePollResponseSchema.safeParse(body);
|
|
3376
|
+
return parsed.success ? parsed.data : null;
|
|
3377
|
+
}
|
|
3378
|
+
function tokenUrlFor(serviceUrl) {
|
|
3379
|
+
return `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
|
|
3380
|
+
}
|
|
3381
|
+
function localDeadline(started) {
|
|
3382
|
+
return new Date(started.expiresAt).getTime() + started.pollIntervalMs * DEADLINE_SLACK_INTERVALS;
|
|
3383
|
+
}
|
|
3359
3384
|
async function pollForCredential(serviceUrl, started, deps = {}) {
|
|
3360
3385
|
const write = deps.write ?? defaultWrite;
|
|
3361
3386
|
const sleep = deps.sleep ?? defaultSleep;
|
|
3362
3387
|
const now = deps.now ?? Date.now;
|
|
3363
|
-
const url =
|
|
3388
|
+
const url = tokenUrlFor(serviceUrl);
|
|
3364
3389
|
const interval = started.pollIntervalMs;
|
|
3365
|
-
const deadline =
|
|
3390
|
+
const deadline = localDeadline(started);
|
|
3366
3391
|
const startedAt = now();
|
|
3367
3392
|
let announcedAt = startedAt;
|
|
3368
3393
|
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
|
-
}
|
|
3394
|
+
const answer = await askControlOnce(url, started);
|
|
3395
|
+
if (answer !== null) {
|
|
3396
|
+
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3397
|
+
return answer;
|
|
3398
|
+
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3399
|
+
throw new SignInRefused(answer.status, answer.message);
|
|
3390
3400
|
}
|
|
3391
3401
|
}
|
|
3392
3402
|
if (now() >= deadline) {
|
|
3393
3403
|
throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_TIMEOUT_MESSAGE);
|
|
3394
3404
|
}
|
|
3395
|
-
if (now() - announcedAt >= STILL_WAITING_MS) {
|
|
3405
|
+
if (now() - announcedAt >= (deps.stillWaitingMs ?? STILL_WAITING_MS)) {
|
|
3396
3406
|
announcedAt = now();
|
|
3397
|
-
write(
|
|
3398
|
-
`);
|
|
3407
|
+
write(stillWaitingLine(announcedAt - startedAt, started));
|
|
3399
3408
|
}
|
|
3400
3409
|
await sleep(interval);
|
|
3401
3410
|
}
|
|
3402
3411
|
}
|
|
3412
|
+
function stillWaitingLine(elapsedMs, started) {
|
|
3413
|
+
return `[halfcycle] Still waiting for the browser (${Math.round(elapsedMs / 1e3)}s). Code: ${started.userCode}
|
|
3414
|
+
`;
|
|
3415
|
+
}
|
|
3403
3416
|
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
|
-
|
|
3417
|
+
async function pollBesideLoopback(serviceUrl, started, wake, deps) {
|
|
3418
|
+
const sleep = deps.sleep;
|
|
3419
|
+
const now = deps.now ?? Date.now;
|
|
3420
|
+
const url = tokenUrlFor(serviceUrl);
|
|
3421
|
+
const deadline = localDeadline(started);
|
|
3422
|
+
let arrived = false;
|
|
3423
|
+
let spent = false;
|
|
3424
|
+
let interrupt = null;
|
|
3425
|
+
void wake.then(() => {
|
|
3426
|
+
arrived = true;
|
|
3427
|
+
if (interrupt !== null)
|
|
3428
|
+
interrupt();
|
|
3429
|
+
});
|
|
3430
|
+
for (; ; ) {
|
|
3431
|
+
const answer = await askControlOnce(url, started);
|
|
3432
|
+
if (answer !== null) {
|
|
3433
|
+
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3434
|
+
return answer;
|
|
3435
|
+
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3436
|
+
throw new SignInRefused(answer.status, answer.message);
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
if (now() >= deadline) {
|
|
3440
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
|
|
3423
3441
|
}
|
|
3424
|
-
|
|
3425
|
-
|
|
3442
|
+
if (arrived && !spent) {
|
|
3443
|
+
spent = true;
|
|
3426
3444
|
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
3445
|
}
|
|
3446
|
+
if (sleep === void 0) {
|
|
3447
|
+
await new Promise((resolve4) => {
|
|
3448
|
+
const timer = setTimeout(resolve4, started.pollIntervalMs);
|
|
3449
|
+
interrupt = () => {
|
|
3450
|
+
clearTimeout(timer);
|
|
3451
|
+
resolve4();
|
|
3452
|
+
};
|
|
3453
|
+
});
|
|
3454
|
+
} else {
|
|
3455
|
+
await new Promise((resolve4) => {
|
|
3456
|
+
interrupt = resolve4;
|
|
3457
|
+
void sleep(started.pollIntervalMs).then(resolve4);
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
interrupt = null;
|
|
3461
|
+
if (arrived)
|
|
3462
|
+
spent = true;
|
|
3433
3463
|
}
|
|
3434
|
-
throw new SignInRefused(SIGN_IN_TIMED_OUT, lastProblem);
|
|
3435
3464
|
}
|
|
3436
3465
|
async function signIn(serviceUrl, deps = {}) {
|
|
3437
3466
|
const write = deps.write ?? defaultWrite;
|
|
@@ -3456,24 +3485,16 @@ async function signIn(serviceUrl, deps = {}) {
|
|
|
3456
3485
|
const url = loopbackVerificationUrl(started.verificationUrl, bind.target);
|
|
3457
3486
|
const attempt = await openBrowser(url, env);
|
|
3458
3487
|
if (attempt.opened) {
|
|
3459
|
-
write(`[halfcycle] Opened your browser to finish signing in. This terminal is waiting.
|
|
3488
|
+
write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. This terminal is waiting.
|
|
3460
3489
|
`);
|
|
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
|
-
)
|
|
3490
|
+
const startedAt = now();
|
|
3491
|
+
const redirect = bind.waitForCallback({
|
|
3492
|
+
timeoutMs: localDeadline(started) - startedAt,
|
|
3493
|
+
onStillWaiting: (elapsed) => write(stillWaitingLine(elapsed, started)),
|
|
3494
|
+
stillWaitingMs: deps.stillWaitingMs ?? STILL_WAITING_MS
|
|
3471
3495
|
});
|
|
3496
|
+
const approved = await pollBesideLoopback(serviceUrl, started, redirect.then(() => void 0), deps);
|
|
3472
3497
|
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
3498
|
write(`[halfcycle] Signed in as account ${approved.accountId} \u2014 continuing.
|
|
3478
3499
|
`);
|
|
3479
3500
|
return {
|
|
@@ -6209,10 +6230,13 @@ ${USAGE}`);
|
|
|
6209
6230
|
if (result.notShared)
|
|
6210
6231
|
process.stdout.write(`${renderNotShared(result.notShared)}
|
|
6211
6232
|
`);
|
|
6233
|
+
if (credential) {
|
|
6234
|
+
process.stdout.write("[halfcycle] Commit the files Halfcycle set up here, so every copy of this project runs the same checks.\n");
|
|
6235
|
+
}
|
|
6212
6236
|
if (credential) {
|
|
6213
6237
|
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
6238
|
`);
|
|
6215
|
-
process.stdout.write(`[halfcycle] Claude Code will
|
|
6239
|
+
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
6240
|
`);
|
|
6217
6241
|
}
|
|
6218
6242
|
if (credential) {
|