@indigoai-us/hq-cli 5.345.145 → 5.345.147

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.
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.82","bundleSha256":"0bf6cd65150094c035cb4ee532c00bde78d030ad0d2132aa606c566f11997f41","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.87","bundleSha256":"916fb28a230f0f0dd83cfaa87366670512898636a137c74a4c989a261301cf5e","formatVersion":1}
@@ -77,6 +77,8 @@ export function renderBotLaunchdPlist(p) {
77
77
  ["HOME", p.home],
78
78
  ["PATH", p.pathEntries.join(":")],
79
79
  ["HQ_NO_UPDATE_CHECK", "1"],
80
+ // Bots must never replace/relaunch the interactive desktop, including during setup.
81
+ ["HQ_NO_ENSURE_HQ_DESKTOP", "1"],
80
82
  ...(p.hqRoot ? [["HQ_ROOT", p.hqRoot]] : []),
81
83
  ]
82
84
  .map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
@@ -135,6 +137,7 @@ RestartSec=5
135
137
  Environment=HOME=${p.home}
136
138
  Environment=PATH=${p.pathEntries.join(":")}
137
139
  Environment=HQ_NO_UPDATE_CHECK=1
140
+ Environment=HQ_NO_ENSURE_HQ_DESKTOP=1
138
141
  ${p.hqRoot ? `Environment=HQ_ROOT=${p.hqRoot}\n` : ""}StandardOutput=append:${p.logPath}
139
142
  StandardError=append:${p.logPath}
140
143
 
@@ -345,7 +345,9 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
345
345
  const recheck = memo !== undefined && (nowMs() - memo.at >= MODEL_FALLBACK_RECHECK_MS || memo.turns >= MODEL_FALLBACK_RECHECK_TURNS);
346
346
  const remembered = memo && !recheck ? memo : undefined;
347
347
  const args = runtime.buildArgs(remembered ? { ...turnInput, model: remembered.to } : turnInput, { outputFile, newSessionId });
348
- const env = input.env ?? deps.env ?? process.env;
348
+ // Enforce at the model boundary too: old plists and direct `bot run` callers
349
+ // may lack the guard. A bot prompt must never quit the user's desktop app.
350
+ const env = { ...(input.env ?? deps.env ?? process.env), HQ_NO_ENSURE_HQ_DESKTOP: "1" };
349
351
  const binary = runtime.resolveBinary?.(env, input.cwd, turnInput.platform) ?? runtime.binary;
350
352
  const stdin = runtime.stdinInput?.(turnInput);
351
353
  const protocol = runtime.spawnProtocol?.(turnInput);
@@ -1,6 +1,6 @@
1
1
  import type { LaneRecord, LaneState, LanesConfig } from "../lanes/types.js";
2
2
  import { type LaneProvider } from "../lanes/types.js";
3
- import { type NowLimit, type NowView } from "./ledger.js";
3
+ import { type NowAccount, type NowLimit, type NowView } from "./ledger.js";
4
4
  /** All lane providers, in the stable order boss-route uses for ranking ties. */
5
5
  export declare const ROUTE_PROVIDERS: readonly LaneProvider[];
6
6
  /** Lane states that count against an account's live load. */
@@ -28,6 +28,12 @@ export declare const OLD_READING_PENALTY = 10;
28
28
  * view stale). Sits below any account with fresh known headroom.
29
29
  */
30
30
  export declare const UNKNOWN_USAGE_SCORE = -100;
31
+ /**
32
+ * Score for an account whose plan limit is used up but which still runs on
33
+ * paid extra usage. Ranked only with --allow-extra-usage, below every account
34
+ * with plan room or unknown usage.
35
+ */
36
+ export declare const EXTRA_USAGE_SCORE = -200;
31
37
  /** Default --floor applied by the CLI (percent headroom to clear). */
32
38
  export declare const DEFAULT_FLOOR = 15;
33
39
  /** Default --prefer order applied by the CLI. */
@@ -85,6 +91,8 @@ export interface RouteRanked {
85
91
  lane_flags: string;
86
92
  /** True when no usable reading exists and the account is ranked as unknown. */
87
93
  usage_unknown: boolean;
94
+ /** True when a plan limit is used up and the lane would spend paid extra usage. */
95
+ extra_usage?: boolean;
88
96
  }
89
97
  /** Live lane on an account the router would avoid; command names a replacement. */
90
98
  export interface RouteMove {
@@ -130,6 +138,12 @@ export interface PickAccountInput {
130
138
  capacityStale?: boolean;
131
139
  /** Error summary when the lanes list could not be read. */
132
140
  lanesError?: string | null;
141
+ /**
142
+ * Owner opt-in (2026-10-17): rank an account whose plan limit is used up
143
+ * but whose Claude extra usage is enabled and not capped. Off by default so
144
+ * lanes never spend paid usage without being asked to.
145
+ */
146
+ allowExtraUsage?: boolean;
133
147
  }
134
148
  export declare class RouteUsageError extends Error {
135
149
  readonly code = "route_usage";
@@ -141,6 +155,12 @@ export declare function resolveRequestedProviders(raw: readonly string[] | undef
141
155
  export declare function resolvePreferOrder(raw: readonly string[] | undefined): LaneProvider[];
142
156
  /** Parse `--floor <n>`; 0..100 inclusive, default DEFAULT_FLOOR when missing. */
143
157
  export declare function resolveFloor(raw: string | number | undefined): number;
158
+ /**
159
+ * True when the account's Claude extra usage reading says the provider will
160
+ * keep serving it after a plan limit runs out: enabled, not at its monthly
161
+ * cap, and not stopped by a spend control.
162
+ */
163
+ export declare function hasExtraUsageRoom(account: NowAccount): boolean;
144
164
  /**
145
165
  * Pure routing decision over a snapshot: scores every signed-in lane account,
146
166
  * applies the --prefer order under the --floor headroom, returns the pick (or
@@ -47,6 +47,12 @@ export const OLD_READING_PENALTY = 10;
47
47
  * view stale). Sits below any account with fresh known headroom.
48
48
  */
49
49
  export const UNKNOWN_USAGE_SCORE = -100;
50
+ /**
51
+ * Score for an account whose plan limit is used up but which still runs on
52
+ * paid extra usage. Ranked only with --allow-extra-usage, below every account
53
+ * with plan room or unknown usage.
54
+ */
55
+ export const EXTRA_USAGE_SCORE = -200;
50
56
  /** Default --floor applied by the CLI (percent headroom to clear). */
51
57
  export const DEFAULT_FLOOR = 15;
52
58
  /** Default --prefer order applied by the CLI. */
@@ -178,6 +184,27 @@ function liveLaneCount(lanes, provider, account) {
178
184
  }
179
185
  return count;
180
186
  }
187
+ /**
188
+ * True when the account's Claude extra usage reading says the provider will
189
+ * keep serving it after a plan limit runs out: enabled, not at its monthly
190
+ * cap, and not stopped by a spend control.
191
+ */
192
+ export function hasExtraUsageRoom(account) {
193
+ const limits = account.limits ?? [];
194
+ const extra = limits.find((l) => l.kind === "credits" && l.limitId === "extra_usage");
195
+ if (!extra || extra.hasCredits !== true)
196
+ return false;
197
+ if (extra.spendControlReached === true)
198
+ return false;
199
+ if (typeof extra.usedPercent === "number" && extra.usedPercent >= 100)
200
+ return false;
201
+ const spend = limits.find((l) => l.kind === "credits" && l.limitId === "spend");
202
+ if (spend?.spendControlReached === true)
203
+ return false;
204
+ if (spend && typeof spend.usedPercent === "number" && spend.usedPercent >= 100)
205
+ return false;
206
+ return true;
207
+ }
181
208
  function limitAgeMs(view, nowMs) {
182
209
  if (!view.resetPassed || !view.limit.resetsAt)
183
210
  return view.readAge;
@@ -368,9 +395,49 @@ export function pickAccount(input) {
368
395
  && row.readAge <= LIMIT_STALE_MS
369
396
  && (row.limit.state === "exhausted" || (typeof row.used === "number" && row.used >= 100)));
370
397
  if (exhausted) {
398
+ const extraRoom = providerId === "claude" && hasExtraUsageRoom(account);
399
+ if (extraRoom && input.allowExtraUsage === true) {
400
+ const live = liveLaneCount(lanes, providerId, laneMatch.id);
401
+ const weekly = applicable.find((l) => l.kind === "weekly_all");
402
+ const whyParts = [
403
+ `${labelLimit(exhausted.limit)} used up; runs on paid extra usage (--allow-extra-usage)`,
404
+ ];
405
+ if (live)
406
+ whyParts.push(`${live} live lane${live === 1 ? "" : "s"} already on it`);
407
+ if (tokenExpired)
408
+ whyParts.push("sign-in renews when the lane starts");
409
+ ranked.push({
410
+ provider: providerId,
411
+ account: laneMatch.id,
412
+ label,
413
+ organization: account.organization ?? null,
414
+ headroom: 0,
415
+ score: EXTRA_USAGE_SCORE - live * LANE_PENALTY,
416
+ live_lanes: live,
417
+ reset_soon: false,
418
+ token_expired: tokenExpired,
419
+ reading_age_h: Math.round(limitAgeMs(exhausted, nowMs) / 36e5),
420
+ binding: {
421
+ limit: labelLimit(exhausted.limit),
422
+ used_percent: typeof exhausted.used === "number" ? exhausted.used : 100,
423
+ resets_at: exhausted.limit.resetsAt ?? null,
424
+ },
425
+ weekly_resets_at: weekly?.resetsAt ?? null,
426
+ why: whyParts.join("; "),
427
+ lane_flags: `--provider ${providerId} --account ${laneMatch.id}`,
428
+ usage_unknown: false,
429
+ extra_usage: true,
430
+ });
431
+ continue;
432
+ }
371
433
  skipped.push({
372
434
  ...base,
373
- reason: `${labelLimit(exhausted.limit)} used up`,
435
+ reason: extraRoom
436
+ ? `${labelLimit(exhausted.limit)} used up; extra usage is on`
437
+ : `${labelLimit(exhausted.limit)} used up`,
438
+ ...(extraRoom
439
+ ? { fix: "pass --allow-extra-usage to run on paid extra usage" }
440
+ : {}),
374
441
  resets_at: exhausted.limit.resetsAt ?? null,
375
442
  });
376
443
  continue;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Release-shipped links (hq-core mode 120000). An update that junctions a
3
+ * directory at the staging clone, then deletes that clone, leaves a link that
4
+ * does not resolve (feedback_3e1a7445). `hq doctor` reports those and the
5
+ * repair step. A link that resolves somewhere else inside the HQ root is a
6
+ * user customization and is left alone.
7
+ */
8
+ import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
9
+ export declare const RELEASE_LINKS_FAMILY_ID = "release-links";
10
+ export declare const RELEASE_LINKS_FAMILY_TITLE = "Release links";
11
+ export declare const RELEASE_LINKS_CHECK_ID = "release-links.resolve";
12
+ export declare const RELEASE_LINK_REMEDIATION = "Run `hq rescue -y` again. On Windows without Developer Mode, a directory link is written as a junction and a file link as a copy.";
13
+ interface ReleaseLink {
14
+ rel: string;
15
+ linkText: string;
16
+ kind: "dir" | "file";
17
+ }
18
+ /** Paths git records as symlinks in the hq-core release. */
19
+ export declare const RELEASE_LINKS: readonly ReleaseLink[];
20
+ export declare const releaseLinksFamily: CheckFamily;
21
+ export declare function checkReleaseLinks(context: CheckContext): CheckResult[];
22
+ export {};
23
+ //# sourceMappingURL=release-links.d.ts.map
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Release-shipped links (hq-core mode 120000). An update that junctions a
3
+ * directory at the staging clone, then deletes that clone, leaves a link that
4
+ * does not resolve (feedback_3e1a7445). `hq doctor` reports those and the
5
+ * repair step. A link that resolves somewhere else inside the HQ root is a
6
+ * user customization and is left alone.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ export const RELEASE_LINKS_FAMILY_ID = "release-links";
11
+ export const RELEASE_LINKS_FAMILY_TITLE = "Release links";
12
+ export const RELEASE_LINKS_CHECK_ID = "release-links.resolve";
13
+ export const RELEASE_LINK_REMEDIATION = "Run `hq rescue -y` again. On Windows without Developer Mode, a directory link is written as a junction and a file link as a copy.";
14
+ const CYGWIN_SYMLINK_COOKIE = Buffer.from("!<symlink>", "latin1");
15
+ /** Paths git records as symlinks in the hq-core release. */
16
+ export const RELEASE_LINKS = [
17
+ { rel: ".agents/skills", linkText: "../.claude/skills", kind: "dir" },
18
+ { rel: ".codex/claude", linkText: "../.claude", kind: "dir" },
19
+ {
20
+ rel: ".codex/output-style.md",
21
+ linkText: "../.claude/output-styles/hq.md",
22
+ kind: "file",
23
+ },
24
+ { rel: "AGENTS.md", linkText: ".claude/CLAUDE.md", kind: "file" },
25
+ {
26
+ rel: "companies/_template/.obsidian",
27
+ linkText: "../../.obsidian",
28
+ kind: "dir",
29
+ },
30
+ ];
31
+ export const releaseLinksFamily = {
32
+ id: RELEASE_LINKS_FAMILY_ID,
33
+ title: RELEASE_LINKS_FAMILY_TITLE,
34
+ run: (context) => Promise.resolve(checkReleaseLinks(context)),
35
+ };
36
+ export function checkReleaseLinks(context) {
37
+ const present = RELEASE_LINKS.filter((entry) => lstatOrNull(path.join(context.hqRoot, entry.rel)) !== null);
38
+ if (present.length === 0) {
39
+ return [
40
+ {
41
+ status: "NA",
42
+ checkId: RELEASE_LINKS_CHECK_ID,
43
+ target: context.hqRoot,
44
+ message: "Skipped: this HQ root has none of the release-shipped links.",
45
+ },
46
+ ];
47
+ }
48
+ const failures = [];
49
+ for (const entry of present) {
50
+ const problem = releaseLinkProblem(context.hqRoot, entry);
51
+ if (!problem)
52
+ continue;
53
+ failures.push({
54
+ status: "FAIL",
55
+ checkId: RELEASE_LINKS_CHECK_ID,
56
+ target: entry.rel,
57
+ message: `${entry.rel} ${problem}`,
58
+ remediation: RELEASE_LINK_REMEDIATION,
59
+ });
60
+ }
61
+ if (failures.length > 0)
62
+ return failures;
63
+ return [
64
+ {
65
+ status: "PASS",
66
+ checkId: RELEASE_LINKS_CHECK_ID,
67
+ target: context.hqRoot,
68
+ message: "Release links resolve inside this HQ root.",
69
+ },
70
+ ];
71
+ }
72
+ function releaseLinkProblem(hqRoot, entry) {
73
+ const destination = path.join(hqRoot, entry.rel);
74
+ const lst = lstatOrNull(destination);
75
+ if (!lst)
76
+ return null;
77
+ if (lst.isSymbolicLink()) {
78
+ let resolved;
79
+ try {
80
+ resolved = fs.realpathSync.native(destination);
81
+ }
82
+ catch {
83
+ return "does not resolve. Its target is missing, often because an update linked it at a staging folder that was then deleted.";
84
+ }
85
+ if (!pathIsInside(hqRoot, resolved)) {
86
+ return "points outside this HQ root, so it does not resolve to the shipped target.";
87
+ }
88
+ return null;
89
+ }
90
+ if (lst.isFile()) {
91
+ const bytes = readFileOrNull(destination);
92
+ if (bytes && isSymlinkStub(bytes, entry.linkText)) {
93
+ return "is still a Git symlink stub, not a working link.";
94
+ }
95
+ if (entry.kind === "dir") {
96
+ return "is a file, not a directory link.";
97
+ }
98
+ const expected = containedTarget(hqRoot, entry);
99
+ if (expected) {
100
+ const target = readFileOrNull(expected);
101
+ if (target && bytes && bytes.equals(target))
102
+ return null;
103
+ }
104
+ // A regular file that is not the stub is a user edit or a Windows copy
105
+ // of the target. Either way the path is usable.
106
+ return null;
107
+ }
108
+ // A real directory in place of a directory link is a user replacement.
109
+ return null;
110
+ }
111
+ function containedTarget(hqRoot, entry) {
112
+ const destination = path.join(hqRoot, entry.rel);
113
+ const resolved = path.resolve(path.dirname(destination), entry.linkText);
114
+ return pathIsInside(hqRoot, resolved) ? resolved : null;
115
+ }
116
+ function isSymlinkStub(bytes, linkText) {
117
+ if (bytes.equals(Buffer.from(linkText, "utf8")))
118
+ return true;
119
+ return (bytes.length >= CYGWIN_SYMLINK_COOKIE.length &&
120
+ bytes.subarray(0, CYGWIN_SYMLINK_COOKIE.length).equals(CYGWIN_SYMLINK_COOKIE));
121
+ }
122
+ function pathIsInside(parent, child) {
123
+ const parentAbs = path.resolve(parent);
124
+ const childAbs = path.resolve(child);
125
+ if (childAbs === parentAbs || childAbs.startsWith(parentAbs + path.sep))
126
+ return true;
127
+ try {
128
+ const parentReal = fs.realpathSync.native(parentAbs);
129
+ const childReal = fs.realpathSync.native(childAbs);
130
+ return childReal === parentReal || childReal.startsWith(parentReal + path.sep);
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ function lstatOrNull(target) {
137
+ try {
138
+ return fs.lstatSync(target);
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ function readFileOrNull(target) {
145
+ try {
146
+ return fs.readFileSync(target);
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ //# sourceMappingURL=release-links.js.map
@@ -26,6 +26,7 @@ import { botsFamily } from "./checks/bots.js";
26
26
  import { anywhereFamily } from "./checks/anywhere.js";
27
27
  import { scaffoldCliFloorFamily } from "./checks/scaffold-cli-floor.js";
28
28
  import { strandedLanesFamily } from "./checks/stranded-lanes.js";
29
+ import { releaseLinksFamily } from "./checks/release-links.js";
29
30
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
30
31
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
31
32
  /**
@@ -270,6 +271,8 @@ export function createDefaultRegistry() {
270
271
  // Failed keep-alive lead lanes cannot recover themselves; make them visible
271
272
  // in the standard local doctor report with the senior recovery command.
272
273
  registry.register(strandedLanesFamily);
274
+ // Release symlinks left pointing at a deleted update staging folder.
275
+ registry.register(releaseLinksFamily);
273
276
  return registry;
274
277
  }
275
278
  //# sourceMappingURL=registry.js.map
@@ -23,6 +23,12 @@ export interface GenerateWorkersRegistryResult {
23
23
  * own, and because duplicates resolve by path order — a tenant path sorts before
24
24
  * both `core` and `personal` — the checkout wins. The operator's actual worker
25
25
  * then silently vanishes from the registry every skill reads.
26
+ *
27
+ * Claude Code worktrees (`.claude/worktrees/<name>/`) and Codex worktrees
28
+ * (`.codex/worktrees/<name>/`) are the same trap in a different folder: a
29
+ * nested HQ root under a company project. Those copies land in the registry as
30
+ * `companies/<slug>/…/.claude/worktrees/…` or `.codex/worktrees/…` paths, so
31
+ * `hq lanes create` then fails with `worker_tenancy_unresolved`.
26
32
  */
27
33
  export declare function isExcludedWorkerDirectory(relativeDirectory: string): boolean;
28
34
  /**
@@ -157,9 +157,17 @@ function withoutTimestamp(content) {
157
157
  * own, and because duplicates resolve by path order — a tenant path sorts before
158
158
  * both `core` and `personal` — the checkout wins. The operator's actual worker
159
159
  * then silently vanishes from the registry every skill reads.
160
+ *
161
+ * Claude Code worktrees (`.claude/worktrees/<name>/`) and Codex worktrees
162
+ * (`.codex/worktrees/<name>/`) are the same trap in a different folder: a
163
+ * nested HQ root under a company project. Those copies land in the registry as
164
+ * `companies/<slug>/…/.claude/worktrees/…` or `.codex/worktrees/…` paths, so
165
+ * `hq lanes create` then fails with `worker_tenancy_unresolved`.
160
166
  */
161
167
  export function isExcludedWorkerDirectory(relativeDirectory) {
162
168
  const normalized = relativeDirectory.replaceAll('\\', '/');
169
+ if (/(?:^|\/)\.(?:claude|codex)\/worktrees(?:\/|$)/.test(normalized))
170
+ return true;
163
171
  const [root, tenant, nested] = normalized.split('/');
164
172
  if (root !== 'companies')
165
173
  return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
@@ -0,0 +1,2 @@
1
+ export declare function isHeadless(): boolean;
2
+ //# sourceMappingURL=headless.d.ts.map
@@ -0,0 +1,8 @@
1
+ export function isHeadless() {
2
+ if (process.env.CI)
3
+ return true;
4
+ if (process.platform !== "linux")
5
+ return false;
6
+ return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
7
+ }
8
+ //# sourceMappingURL=headless.js.map
@@ -0,0 +1,4 @@
1
+ import type { CognitoAuthConfig, CognitoTokens } from "@indigoai-us/hq-cloud";
2
+ /** Keep desktop login unchanged; manual login uses the same PKCE loopback flow. */
3
+ export declare function loginWithBrowserPreference(config: CognitoAuthConfig, browser?: boolean): Promise<CognitoTokens>;
4
+ //# sourceMappingURL=login-browser.d.ts.map
@@ -0,0 +1,122 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { browserLogin, saveCachedTokens, CognitoAuthError } from "@indigoai-us/hq-cloud";
4
+ import { isHeadless } from "./headless.js";
5
+ function assertHumanLoginAllowed() {
6
+ // Preserve hq-cloud's company-machine boundary on the manual URL path.
7
+ if (["1", "true", "yes"].includes(process.env.HQ_MACHINE_AUTH_REQUIRED?.trim().toLowerCase() ?? "")) {
8
+ throw new CognitoAuthError("Human authentication is unavailable in required company machine mode");
9
+ }
10
+ }
11
+ /** Keep desktop login unchanged; manual login uses the same PKCE loopback flow. */
12
+ export async function loginWithBrowserPreference(config, browser = true) {
13
+ if (browser && !isHeadless())
14
+ return browserLogin(config);
15
+ assertHumanLoginAllowed();
16
+ const port = config.port ?? 3000;
17
+ const redirectUri = `http://localhost:${port}/callback`;
18
+ const base = `https://${config.userPoolDomain}.auth.${config.region}.amazoncognito.com`;
19
+ const verifier = randomBytes(32).toString("base64url");
20
+ const state = randomBytes(16).toString("base64url");
21
+ const url = new URL(`${base}/oauth2/authorize`);
22
+ url.search = new URLSearchParams({
23
+ client_id: config.clientId,
24
+ response_type: "code",
25
+ scope: (config.scopes ?? ["openid", "email", "profile"]).join(" "),
26
+ redirect_uri: redirectUri,
27
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
28
+ code_challenge_method: "S256",
29
+ state,
30
+ }).toString();
31
+ if (config.identityProvider)
32
+ url.searchParams.set("identity_provider", config.identityProvider);
33
+ if (config.prompt)
34
+ url.searchParams.set("prompt", config.prompt);
35
+ const code = await new Promise((resolve, reject) => {
36
+ let settled = false;
37
+ const handler = (req, res) => {
38
+ if (settled) {
39
+ res.writeHead(410).end("Sign-in already finished");
40
+ return;
41
+ }
42
+ const callback = new URL(req.url ?? "/", redirectUri);
43
+ if (callback.pathname !== "/callback") {
44
+ res.writeHead(404).end("Not found");
45
+ return;
46
+ }
47
+ const receivedState = Buffer.from(callback.searchParams.get("state") ?? "");
48
+ const expectedState = Buffer.from(state);
49
+ const validState = receivedState.length === expectedState.length
50
+ && timingSafeEqual(receivedState, expectedState);
51
+ const code = callback.searchParams.get("code");
52
+ if (!validState || !code || callback.searchParams.has("error")) {
53
+ res.writeHead(400).end("Sign-in failed. Return to your terminal and try again.");
54
+ finish(new CognitoAuthError(validState ? "Sign-in callback was denied or missing a code" : "Cognito state parameter mismatch"));
55
+ return;
56
+ }
57
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("Sign-in response received. Return to your terminal to finish.");
58
+ finish();
59
+ resolve(code);
60
+ };
61
+ const servers = [createServer(handler), createServer(handler)];
62
+ const timer = setTimeout(() => finish(new CognitoAuthError("Login timed out after 15 minutes")), 15 * 60 * 1000);
63
+ function finish(error) {
64
+ settled = true;
65
+ clearTimeout(timer);
66
+ for (const server of servers)
67
+ server.close();
68
+ if (error)
69
+ reject(error);
70
+ }
71
+ let attempts = 0;
72
+ let listening = 0;
73
+ let lastError;
74
+ const ready = () => {
75
+ if (++attempts !== servers.length)
76
+ return;
77
+ if (!listening) {
78
+ finish(lastError ?? new Error("Cannot bind login callback port"));
79
+ return;
80
+ }
81
+ try {
82
+ assertHumanLoginAllowed();
83
+ console.log(`Open this URL to sign in:\n${url.toString()}`);
84
+ console.log(`Waiting for sign-in. For a remote host, forward local port ${port} to this host's port ${port} before opening the URL.`);
85
+ }
86
+ catch (error) {
87
+ finish(error instanceof Error ? error : new Error("Sign-in unavailable"));
88
+ }
89
+ };
90
+ servers.forEach((server, index) => {
91
+ server.once("error", (error) => { lastError = error; ready(); });
92
+ server.listen(port, index === 0 ? "127.0.0.1" : "::1", () => { listening++; ready(); });
93
+ });
94
+ });
95
+ assertHumanLoginAllowed();
96
+ const response = await fetch(`${base}/oauth2/token`, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
99
+ body: new URLSearchParams({ grant_type: "authorization_code", client_id: config.clientId, code, code_verifier: verifier, redirect_uri: redirectUri }),
100
+ signal: AbortSignal.timeout(30_000),
101
+ });
102
+ // Never echo an OAuth response body, which can contain credentials.
103
+ if (!response.ok)
104
+ throw new CognitoAuthError(`Token exchange failed (${response.status})`);
105
+ const data = await response.json().catch(() => {
106
+ throw new CognitoAuthError("Cognito returned an invalid token response");
107
+ });
108
+ if (!data || typeof data.access_token !== "string" || !data.access_token
109
+ || typeof data.id_token !== "string" || !data.id_token
110
+ || typeof data.refresh_token !== "string" || !data.refresh_token
111
+ || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
112
+ throw new CognitoAuthError("Cognito returned an incomplete token response");
113
+ }
114
+ const tokens = {
115
+ accessToken: data.access_token, idToken: data.id_token, refreshToken: data.refresh_token,
116
+ expiresAt: Date.now() + data.expires_in * 1000, tokenType: "Bearer",
117
+ };
118
+ assertHumanLoginAllowed();
119
+ saveCachedTokens(tokens);
120
+ return tokens;
121
+ }
122
+ //# sourceMappingURL=login-browser.js.map
@@ -1,15 +1,9 @@
1
+ import { isHeadless } from "./headless.js";
1
2
  import open from "open";
2
3
  import { ensureCognitoToken } from "./cognito-session.js";
3
4
  import { getCompanyUid, vaultApiFetch } from "./vault-api.js";
4
5
  const CHECKOUT_SUCCESS_URL = "https://hq.computer/billing?checkout=success";
5
6
  const CHECKOUT_CANCEL_URL = "https://hq.computer/billing?checkout=cancel";
6
- function isHeadless() {
7
- if (process.env.CI)
8
- return true;
9
- if (process.platform !== "linux")
10
- return false;
11
- return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
12
- }
13
7
  function ownerUpgradeError() {
14
8
  return Object.assign(new Error("Only a company owner can upgrade. Ask a company owner to upgrade."), { expected: true });
15
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.145",
3
+ "version": "5.345.147",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -48,7 +48,7 @@
48
48
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
49
49
  "@aws-sdk/client-s3": "^3.1049.0",
50
50
  "@aws-sdk/dsql-signer": "^3.1146.0",
51
- "@indigoai-us/hq-cloud": "~6.18.82",
51
+ "@indigoai-us/hq-cloud": "~6.18.87",
52
52
  "@indigoai-us/hq-flags-client": "^0.1.2",
53
53
  "@modelcontextprotocol/sdk": "1.29.0",
54
54
  "@sentry/node": "^10.49.0",