@indigoai-us/hq-cli 5.109.0 → 5.109.2
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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.2] — 2026-09-09
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq integrations connect <app>` now says so when the app is already
|
|
10
|
+
connected, instead of opening a browser and waiting for a sign-in that
|
|
11
|
+
changes nothing. It prints the reconnect command for re-authorizing the
|
|
12
|
+
existing connection, and `--force` still adds a second one. Across a
|
|
13
|
+
few-hundred-app sweep the old behavior read as a silent failure.
|
|
14
|
+
|
|
15
|
+
The check only short-circuits a connection that is genuinely finished. A row
|
|
16
|
+
whose installation is still at `needs_credentials`, `pending`, `error`, or
|
|
17
|
+
`revoked` falls through to the ordinary connect, because that connect is the
|
|
18
|
+
only thing that repairs those states. An installation status the CLI does not
|
|
19
|
+
recognize is treated the same way, so an unknown state costs a redundant
|
|
20
|
+
sign-in rather than a stranded integration.
|
|
21
|
+
|
|
22
|
+
## [5.109.1] — 2026-09-08
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `hq access` records each outcome (outcome, company, timestamp only) to
|
|
27
|
+
`.hq/access-outcomes.jsonl` so the cause mix can be reviewed later.
|
|
28
|
+
|
|
5
29
|
## [5.109.0] — 2026-09-08
|
|
6
30
|
|
|
7
31
|
### Added
|
package/dist/commands/access.js
CHANGED
|
@@ -26,6 +26,7 @@ import { listActiveMembers } from "./members.js";
|
|
|
26
26
|
import { buildDmBody } from "./dm.js";
|
|
27
27
|
import { peekIdToken } from "../utils/id-token.js";
|
|
28
28
|
import { findRecentAccessRequest, formatTimeAgo, recordAccessRequest, } from "../utils/access-requests.js";
|
|
29
|
+
import { recordAccessOutcome } from "../utils/access-outcomes.js";
|
|
29
30
|
export function outcomeExitCode(outcome) {
|
|
30
31
|
if (outcome === "local" || outcome === "not-synced")
|
|
31
32
|
return 0;
|
|
@@ -146,7 +147,25 @@ function aclDenied(tree) {
|
|
|
146
147
|
const perm = tree.effectivePermission;
|
|
147
148
|
return perm === null || perm === "none" || perm === "deny";
|
|
148
149
|
}
|
|
149
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Build the result and record one cause-mix telemetry line. Every exit path
|
|
152
|
+
* goes through here, so each run is recorded exactly once. The write is
|
|
153
|
+
* best-effort: a failure adds a step + one stderr warning and never changes
|
|
154
|
+
* the outcome or exit code. Only outcome, company, and timestamp are stored.
|
|
155
|
+
*/
|
|
156
|
+
function finish(outcome, fields, ctx) {
|
|
157
|
+
try {
|
|
158
|
+
recordAccessOutcome(ctx.hqRoot, {
|
|
159
|
+
outcome,
|
|
160
|
+
company: fields.company,
|
|
161
|
+
ts: new Date((ctx.now ?? Date.now)()).toISOString(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
166
|
+
fields.steps.push(`telemetry: write failed — ${msg}`);
|
|
167
|
+
ctx.stderr(`warning: could not record access outcome telemetry: ${msg}`);
|
|
168
|
+
}
|
|
150
169
|
return { outcome, exitCode: outcomeExitCode(outcome), ...fields };
|
|
151
170
|
}
|
|
152
171
|
export function resolveGrantor(tree, members) {
|
|
@@ -194,6 +213,7 @@ export async function runAccess(input) {
|
|
|
194
213
|
const steps = [];
|
|
195
214
|
const relative = normalizeTarget(input.target, slug);
|
|
196
215
|
const anchored = (key) => `companies/${slug}/${key}`;
|
|
216
|
+
const finishCtx = { hqRoot: deps.hqRoot, now: deps.now, stderr };
|
|
197
217
|
let vendDenied = false;
|
|
198
218
|
try {
|
|
199
219
|
await deps.vaultClient.sts.vend({ companyUid: deps.companyUid });
|
|
@@ -252,7 +272,7 @@ export async function runAccess(input) {
|
|
|
252
272
|
company: slug,
|
|
253
273
|
exists: false,
|
|
254
274
|
steps,
|
|
255
|
-
});
|
|
275
|
+
}, finishCtx);
|
|
256
276
|
emit(input, result, log);
|
|
257
277
|
return result;
|
|
258
278
|
}
|
|
@@ -275,7 +295,7 @@ export async function runAccess(input) {
|
|
|
275
295
|
exists: false,
|
|
276
296
|
candidates,
|
|
277
297
|
steps,
|
|
278
|
-
});
|
|
298
|
+
}, finishCtx);
|
|
279
299
|
emit(input, result, log);
|
|
280
300
|
return result;
|
|
281
301
|
}
|
|
@@ -511,7 +531,7 @@ export async function runAccess(input) {
|
|
|
511
531
|
...(requestSentAt ? { requestSentAt } : {}),
|
|
512
532
|
...(alreadyAskedAt ? { alreadyAskedAt } : {}),
|
|
513
533
|
...(requestNote ? { requestNote } : {}),
|
|
514
|
-
});
|
|
534
|
+
}, finishCtx);
|
|
515
535
|
emit(input, result, log);
|
|
516
536
|
return result;
|
|
517
537
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* "key" but is really OAuth-protected still connects on the first try.
|
|
18
18
|
*/
|
|
19
19
|
import { Command } from "commander";
|
|
20
|
+
import type { AdminConnection } from "./integrations-core.js";
|
|
20
21
|
/**
|
|
21
22
|
* Validate `--auth` against the supported enum, rejecting an unknown value
|
|
22
23
|
* before anything is installed. A finite provider/runtime option must never be
|
|
@@ -46,5 +47,6 @@ export declare function consoleOrigin(vaultApiUrl?: string): string | undefined;
|
|
|
46
47
|
* by slug), or undefined when the console for this control plane is unknown.
|
|
47
48
|
*/
|
|
48
49
|
export declare function consoleIntegrationsUrl(companySlug: string | undefined, origin: string | undefined): string | undefined;
|
|
50
|
+
export declare function findAlreadyConnected(connections: readonly AdminConnection[], app: string): AdminConnection | undefined;
|
|
49
51
|
export declare function registerConnectCommands(integrations: Command): void;
|
|
50
52
|
//# sourceMappingURL=integrations-connect.d.ts.map
|
|
@@ -20,7 +20,7 @@ import chalk from "chalk";
|
|
|
20
20
|
import open from "open";
|
|
21
21
|
import { DEFAULT_VAULT_API_URL, ensureCognitoIdToken, } from "../utils/cognito-session.js";
|
|
22
22
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
23
|
-
import { IntegrationsCliError, bareProvider, connectionDomain, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
|
|
23
|
+
import { IntegrationsCliError, bareProvider, connectionDomain, fetchAdminSurface, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
|
|
24
24
|
import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBlueprint, startOAuth, } from "./integrations-api.js";
|
|
25
25
|
import { startLoopbackListener } from "./integrations-oauth.js";
|
|
26
26
|
/** hq-pro's machine code for "this endpoint needs a browser sign-in". */
|
|
@@ -886,6 +886,115 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
|
|
|
886
886
|
throw err;
|
|
887
887
|
}
|
|
888
888
|
}
|
|
889
|
+
/**
|
|
890
|
+
* The connection `app` already names, if it is connected RIGHT NOW.
|
|
891
|
+
*
|
|
892
|
+
* `connect` used to start a fresh sign-in for an app that was already
|
|
893
|
+
* connected: it opened a browser, printed an authorize URL and waited, saying
|
|
894
|
+
* nothing about the connection already sitting there. Sweeping a few hundred
|
|
895
|
+
* apps, that reads as a silent failure — you approve a vendor a second time and
|
|
896
|
+
* get no acknowledgement that the first one exists.
|
|
897
|
+
*
|
|
898
|
+
* Only `status === "connected"` counts. A `needs-reauth`, `error` or `revoked`
|
|
899
|
+
* row is NOT a reason to refuse: those are exactly the rows a person runs this
|
|
900
|
+
* command to repair, and blocking them would strand the app.
|
|
901
|
+
*
|
|
902
|
+
* Matching is deliberately anchored on the app's own name. A connected
|
|
903
|
+
* `installation.domain` matches when it IS the app, or is a subdomain of it
|
|
904
|
+
* (`notion.com` is served by `mcp.notion.com`) — never the reverse, so an
|
|
905
|
+
* unrelated host can never be read as this app. The provider slug and display
|
|
906
|
+
* name are matched too, because `connect notion` and `connect notion.com` are
|
|
907
|
+
* the same request.
|
|
908
|
+
*/
|
|
909
|
+
/**
|
|
910
|
+
* Whether a row is finished enough that starting another sign-in would be
|
|
911
|
+
* redundant work rather than the repair the caller needs.
|
|
912
|
+
*
|
|
913
|
+
* `connection.status === "connected"` is NOT sufficient on its own. A row can
|
|
914
|
+
* be connected at the connection level while its installation still sits at
|
|
915
|
+
* `needs_credentials`, `pending`, `error`, or `revoked` — states whose ONLY
|
|
916
|
+
* remedy is the very connect the caller just asked for. Short-circuiting those
|
|
917
|
+
* would strand them: the command would report "already connected" about a thing
|
|
918
|
+
* that cannot be used, and the person would have no way to fix it.
|
|
919
|
+
*
|
|
920
|
+
* So this is an allowlist, not a denylist of known-bad states. An absent
|
|
921
|
+
* installation is treated as settled (a non-factory connection has no
|
|
922
|
+
* installation to be mid-flight), but any PRESENT status other than `installed`
|
|
923
|
+
* is treated as unfinished. A status this CLI has not heard of therefore falls
|
|
924
|
+
* through to the ordinary connect — the safe direction, since a redundant
|
|
925
|
+
* sign-in costs a browser round-trip while a wrong skip costs a broken
|
|
926
|
+
* integration with no visible path out.
|
|
927
|
+
*/
|
|
928
|
+
function isSettledInstallation(connection) {
|
|
929
|
+
const status = connection.installation?.status?.trim().toLowerCase();
|
|
930
|
+
return status === undefined || status === "" || status === "installed";
|
|
931
|
+
}
|
|
932
|
+
export function findAlreadyConnected(connections, app) {
|
|
933
|
+
const want = app.trim().toLowerCase();
|
|
934
|
+
if (want === "")
|
|
935
|
+
return undefined;
|
|
936
|
+
const wantSlug = displayNameSlug(want);
|
|
937
|
+
return connections.find((connection) => {
|
|
938
|
+
if (connection.status !== "connected")
|
|
939
|
+
return false;
|
|
940
|
+
if (!isSettledInstallation(connection))
|
|
941
|
+
return false;
|
|
942
|
+
const domain = (connectionDomain(connection) ?? "").toLowerCase();
|
|
943
|
+
if (domain === want || domain.endsWith(`.${want}`))
|
|
944
|
+
return true;
|
|
945
|
+
if (bareProvider(connection.provider).toLowerCase() === want)
|
|
946
|
+
return true;
|
|
947
|
+
const displayName = (connection.installation?.displayName ?? "").trim();
|
|
948
|
+
if (displayName === "")
|
|
949
|
+
return false;
|
|
950
|
+
if (displayName.toLowerCase() === want)
|
|
951
|
+
return true;
|
|
952
|
+
// `connect aws-marketplace` is the copy-pasteable form of the display name
|
|
953
|
+
// "AWS Marketplace", and it is what the catalog resolver already accepts.
|
|
954
|
+
// Compare on the same slug both ways or this check misses every app whose
|
|
955
|
+
// name has a space in it.
|
|
956
|
+
return wantSlug !== "" && displayNameSlug(displayName) === wantSlug;
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Report an app as already connected instead of opening a second sign-in.
|
|
961
|
+
* Returns true when the connect should stop here.
|
|
962
|
+
*/
|
|
963
|
+
async function refuseIfAlreadyConnected(token, companyUid, app, opts) {
|
|
964
|
+
// Only a plain `connect <app>`. An explicit --mcp-url / --docs-url /
|
|
965
|
+
// --entry-id names an endpoint rather than an installed app, and --force is
|
|
966
|
+
// the deliberate "yes, add another one" escape hatch.
|
|
967
|
+
if (!app || opts.force || opts.mcpUrl || opts.docsUrl || opts.entryId)
|
|
968
|
+
return false;
|
|
969
|
+
let connections;
|
|
970
|
+
try {
|
|
971
|
+
connections = (await fetchAdminSurface(token, companyUid)).connections ?? [];
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
// Never let this convenience check block a connect. If the list cannot be
|
|
975
|
+
// read, fall through and let the real flow report whatever is wrong.
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
const existing = findAlreadyConnected(connections, app);
|
|
979
|
+
if (!existing)
|
|
980
|
+
return false;
|
|
981
|
+
const slug = bareProvider(existing.provider);
|
|
982
|
+
if (opts.json) {
|
|
983
|
+
printJson({
|
|
984
|
+
ok: true,
|
|
985
|
+
alreadyConnected: true,
|
|
986
|
+
provider: slug,
|
|
987
|
+
connection: existing.id,
|
|
988
|
+
...(connectionDomain(existing) ? { domain: connectionDomain(existing) } : {}),
|
|
989
|
+
});
|
|
990
|
+
return true;
|
|
991
|
+
}
|
|
992
|
+
const label = existing.installation?.displayName ?? slug;
|
|
993
|
+
console.log(chalk.green(`${label} is already connected.`));
|
|
994
|
+
console.log(chalk.dim(` Sign in again: hq integrations reconnect --provider ${slug}\n` +
|
|
995
|
+
` Add a second: hq integrations connect ${shellQuote(app)} --force`));
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
889
998
|
export function registerConnectCommands(integrations) {
|
|
890
999
|
integrations
|
|
891
1000
|
.command("catalog [query]")
|
|
@@ -1014,11 +1123,14 @@ export function registerConnectCommands(integrations) {
|
|
|
1014
1123
|
.option("--scopes <list>", "Permissions to request, space- or comma-separated (default: what the app needs)")
|
|
1015
1124
|
.option("--no-browser", "Print the sign-in URL instead of opening a browser")
|
|
1016
1125
|
.option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
|
|
1126
|
+
.option("--force", "Connect again even if this app is already connected")
|
|
1017
1127
|
.option("--json", "Machine-readable output")
|
|
1018
1128
|
.action(async (app, opts) => {
|
|
1019
1129
|
assertAuthMode(opts.auth);
|
|
1020
1130
|
const token = await ensureCognitoIdToken();
|
|
1021
1131
|
const companyUid = await getCompanyUid(token, opts.company);
|
|
1132
|
+
if (await refuseIfAlreadyConnected(token, companyUid, app, opts))
|
|
1133
|
+
return;
|
|
1022
1134
|
await connectApp(token, companyUid, app, opts);
|
|
1023
1135
|
});
|
|
1024
1136
|
integrations
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AccessOutcome } from "../commands/access.js";
|
|
2
|
+
/**
|
|
3
|
+
* One line of the `hq access` cause-mix ledger. Deliberately narrow: no path,
|
|
4
|
+
* requester, or grantor — only enough to review which outcomes users hit.
|
|
5
|
+
*/
|
|
6
|
+
export interface AccessOutcomeRecord {
|
|
7
|
+
outcome: AccessOutcome;
|
|
8
|
+
company: string;
|
|
9
|
+
ts: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function accessOutcomesPath(hqRoot: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Append one JSONL line. Best-effort by contract: callers are expected to
|
|
14
|
+
* catch and downgrade any failure, since telemetry must never change an
|
|
15
|
+
* `hq access` outcome or exit code.
|
|
16
|
+
*/
|
|
17
|
+
export declare function recordAccessOutcome(hqRoot: string, rec: AccessOutcomeRecord): void;
|
|
18
|
+
/**
|
|
19
|
+
* Read the ledger. A missing file is the empty ledger; malformed lines are
|
|
20
|
+
* skipped so one bad append never hides the rest of the history.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readAccessOutcomes(hqRoot: string): AccessOutcomeRecord[];
|
|
23
|
+
//# sourceMappingURL=access-outcomes.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export function accessOutcomesPath(hqRoot) {
|
|
4
|
+
return path.join(hqRoot, ".hq", "access-outcomes.jsonl");
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Append one JSONL line. Best-effort by contract: callers are expected to
|
|
8
|
+
* catch and downgrade any failure, since telemetry must never change an
|
|
9
|
+
* `hq access` outcome or exit code.
|
|
10
|
+
*/
|
|
11
|
+
export function recordAccessOutcome(hqRoot, rec) {
|
|
12
|
+
const file = accessOutcomesPath(hqRoot);
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
14
|
+
const line = JSON.stringify({ outcome: rec.outcome, company: rec.company, ts: rec.ts });
|
|
15
|
+
fs.appendFileSync(file, `${line}\n`, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Read the ledger. A missing file is the empty ledger; malformed lines are
|
|
19
|
+
* skipped so one bad append never hides the rest of the history.
|
|
20
|
+
*/
|
|
21
|
+
export function readAccessOutcomes(hqRoot) {
|
|
22
|
+
const file = accessOutcomesPath(hqRoot);
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = fs.readFileSync(file, "utf-8");
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err.code === "ENOENT")
|
|
29
|
+
return [];
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of raw.split("\n")) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (!trimmed)
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(trimmed);
|
|
39
|
+
if (parsed &&
|
|
40
|
+
typeof parsed === "object" &&
|
|
41
|
+
typeof parsed.outcome === "string" &&
|
|
42
|
+
typeof parsed.company === "string" &&
|
|
43
|
+
typeof parsed.ts === "string") {
|
|
44
|
+
out.push(parsed);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// malformed line: skip
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=access-outcomes.js.map
|