@indigoai-us/hq-cli 5.345.170 → 5.345.172
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 +84 -0
- package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
- package/dist/command-catalog.generated.d.ts +206 -7
- package/dist/command-catalog.generated.js +252 -8
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/access.d.ts +32 -1
- package/dist/commands/access.js +112 -3
- package/dist/commands/agent-kit.d.ts +1 -1
- package/dist/commands/agent-kit.js +3 -1
- package/dist/commands/agents.js +2 -1
- package/dist/commands/bot.d.ts +3 -0
- package/dist/commands/bot.js +179 -18
- package/dist/commands/cloud.d.ts +58 -1
- package/dist/commands/cloud.js +228 -10
- package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
- package/dist/commands/core-company-index-scope-flag.js +9 -2
- package/dist/commands/core-delegate-w1b.js +5 -2
- package/dist/commands/core-dm-bind-native.js +19 -4
- package/dist/commands/core-rebuild-index-fast.js +24 -6
- package/dist/commands/core.js +18 -6
- package/dist/commands/daemon.js +16 -1
- package/dist/commands/dm.d.ts +17 -3
- package/dist/commands/dm.js +121 -26
- package/dist/commands/files.d.ts +1 -0
- package/dist/commands/files.js +22 -4
- package/dist/commands/integrations-api.d.ts +4 -0
- package/dist/commands/integrations-api.js +18 -2
- package/dist/commands/integrations-connect.d.ts +10 -3
- package/dist/commands/integrations-connect.js +70 -6
- package/dist/commands/integrations-core.d.ts +23 -0
- package/dist/commands/integrations-core.js +100 -0
- package/dist/commands/integrations-manage.js +8 -1
- package/dist/commands/integrations.js +49 -5
- package/dist/commands/lanes-loop.d.ts +20 -0
- package/dist/commands/lanes-loop.js +69 -11
- package/dist/commands/lanes.js +9 -1
- package/dist/commands/rescue.d.ts +9 -0
- package/dist/commands/rescue.js +31 -0
- package/dist/commands/run.js +115 -40
- package/dist/commands/scan.d.ts +5 -1
- package/dist/commands/scan.js +16 -4
- package/dist/commands/secrets.js +50 -9
- package/dist/commands/sync-core.d.ts +22 -0
- package/dist/commands/sync-core.js +124 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2447 -2135
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
- package/dist/lib/agent-kit/inbox-state.js +41 -20
- package/dist/lib/agent-kit/kit-config.d.ts +2 -0
- package/dist/lib/agent-kit/kit-config.js +6 -0
- package/dist/lib/agent-kit/mcp/tools.js +12 -5
- package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
- package/dist/lib/agent-kit/run/heartbeat.js +6 -3
- package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
- package/dist/lib/agent-kit/run/inbox.js +42 -10
- package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
- package/dist/lib/agent-kit/run/request-timeout.js +51 -0
- package/dist/lib/bot/api.d.ts +1 -1
- package/dist/lib/bot/config.d.ts +10 -0
- package/dist/lib/bot/config.js +17 -1
- package/dist/lib/bot/fleet-local.d.ts +142 -0
- package/dist/lib/bot/fleet-local.js +689 -0
- package/dist/lib/bot/fleet-python.d.ts +47 -0
- package/dist/lib/bot/fleet-python.js +218 -0
- package/dist/lib/bot/index.d.ts +2 -0
- package/dist/lib/bot/index.js +2 -0
- package/dist/lib/bot/local-config.d.ts +3 -2
- package/dist/lib/bot/local-config.js +5 -2
- package/dist/lib/bot/promote.d.ts +10 -0
- package/dist/lib/bot/promote.js +3 -2
- package/dist/lib/bot/prompt.d.ts +6 -1
- package/dist/lib/bot/prompt.js +8 -3
- package/dist/lib/bot/runtime/index.js +8 -4
- package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
- package/dist/lib/bot/runtime/scratch-dir.js +41 -0
- package/dist/lib/caller-principal.d.ts +4 -0
- package/dist/lib/caller-principal.js +40 -0
- package/dist/lib/capacity/collectors/grok.js +8 -2
- package/dist/lib/cli-flag-keys.d.ts +2 -0
- package/dist/lib/cli-flag-keys.js +2 -0
- package/dist/lib/daemon/catalog.d.ts +12 -4
- package/dist/lib/daemon/catalog.js +70 -24
- package/dist/lib/daemon/config.d.ts +0 -7
- package/dist/lib/daemon/config.js +0 -32
- package/dist/lib/daemon/run.d.ts +5 -1
- package/dist/lib/daemon/run.js +48 -11
- package/dist/lib/daemon/supervisor.js +3 -1
- package/dist/lib/doctor/checks/release-links.d.ts +62 -1
- package/dist/lib/doctor/checks/release-links.js +188 -2
- package/dist/lib/doctor/fix/apply.js +54 -0
- package/dist/lib/doctor/fix/remediation.d.ts +8 -1
- package/dist/lib/doctor/fix/remediation.js +22 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/index-render/index.d.ts +9 -0
- package/dist/lib/index-render/index.js +65 -5
- package/dist/lib/index-render/session-scope.d.ts +69 -0
- package/dist/lib/index-render/session-scope.js +279 -0
- package/dist/lib/lanes/admission.js +7 -20
- package/dist/lib/lanes/reconcile.js +13 -15
- package/dist/lib/lanes/stale-lock.d.ts +50 -0
- package/dist/lib/lanes/stale-lock.js +105 -0
- package/dist/lib/lanes/store.js +11 -76
- package/dist/lib/notify-recipient.d.ts +10 -4
- package/dist/lib/notify-recipient.js +16 -4
- package/dist/lib/secret-input-link.d.ts +42 -0
- package/dist/lib/secret-input-link.js +93 -0
- package/dist/lib/secret-scan/range.d.ts +57 -0
- package/dist/lib/secret-scan/range.js +266 -0
- package/dist/lib/secret-scan/repo-config.d.ts +18 -3
- package/dist/lib/secret-scan/repo-config.js +135 -2
- package/dist/lib/sync-core/engine-home.d.ts +44 -0
- package/dist/lib/sync-core/engine-home.js +193 -0
- package/dist/lib/sync-core/flag.d.ts +17 -0
- package/dist/lib/sync-core/flag.js +47 -0
- package/dist/lib/sync-core/reconcile.d.ts +31 -0
- package/dist/lib/sync-core/reconcile.js +196 -0
- package/dist/lib/sync-core/release.d.ts +32 -0
- package/dist/lib/sync-core/release.js +140 -0
- package/dist/lib/sync-core/rollout-state.d.ts +24 -0
- package/dist/lib/sync-core/rollout-state.js +94 -0
- package/dist/lib/vault-sync-switches.d.ts +5 -0
- package/dist/lib/vault-sync-switches.js +43 -0
- package/dist/run/script-bound-schema.d.ts +27 -0
- package/dist/run/script-bound-schema.js +97 -0
- package/dist/secrets-env-action.d.ts +2 -0
- package/dist/secrets-env-action.js +7 -2
- package/dist/utils/cognito-session.d.ts +3 -30
- package/dist/utils/cognito-session.js +2 -100
- package/dist/utils/machine-principal-state.d.ts +38 -0
- package/dist/utils/machine-principal-state.js +110 -0
- package/dist/utils/remote-list-not-converging.d.ts +21 -0
- package/dist/utils/remote-list-not-converging.js +40 -0
- package/dist/utils/safe-link.js +9 -0
- package/dist/utils/script-bound-exec.d.ts +158 -0
- package/dist/utils/script-bound-exec.js +568 -0
- package/dist/utils/sync-watchdog.d.ts +92 -0
- package/dist/utils/sync-watchdog.js +250 -0
- package/package.json +8 -8
- package/dist/lib/daemon/sync-core-canary.d.ts +0 -9
- package/dist/lib/daemon/sync-core-canary.js +0 -56
package/dist/commands/dm.js
CHANGED
|
@@ -5,7 +5,9 @@ import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.
|
|
|
5
5
|
import { isMachineIdentity } from "@indigoai-us/hq-cloud";
|
|
6
6
|
import { peekIdToken } from "../utils/id-token.js";
|
|
7
7
|
import { redactAgentInboxItem as redactInboxCredentials } from "../lib/agent-kit/inbox-redact.js";
|
|
8
|
-
import {
|
|
8
|
+
import { classifyAgentInboxBody } from "../lib/agent-kit/inbox-state.js";
|
|
9
|
+
import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN, PERSON_AGENT_AUDIENCE_MESSAGE, PERSON_CANNOT_SEND_AGENT_CODE, callerMayUseAudience, defaultAudienceFor, recipientKindOf, } from "../lib/notify-recipient.js";
|
|
10
|
+
import { callerIsMachinePrincipal } from "../lib/caller-principal.js";
|
|
9
11
|
export { defaultAudienceFor, recipientKindOf };
|
|
10
12
|
import { dmForwardCliEnabled } from "./dm-forward-flag.js";
|
|
11
13
|
import { attachmentScopeForChannel, formatUploadedAttachments, personalScopeForCaller, uploadDmAttachments, validateAttachmentPaths, } from "./dm-attach.js";
|
|
@@ -120,29 +122,45 @@ export function matchChannelsByName(channels, name) {
|
|
|
120
122
|
* eba06f59). Returns null when nothing or more than one contact matches. Pure.
|
|
121
123
|
*/
|
|
122
124
|
export function matchContactByName(contacts, name) {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
+
const unique = matchContactsByName(contacts, name);
|
|
126
|
+
return unique.length === 1 ? unique[0] : null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Every distinct contact (person or bot) whose display name matches `name`,
|
|
130
|
+
* using the same rule as matchContactByName. A leading '@' is the explicit
|
|
131
|
+
* person/bot selector and is ignored for matching. Pure.
|
|
132
|
+
*/
|
|
133
|
+
export function matchContactsByName(contacts, name) {
|
|
134
|
+
const bare = name.trim().replace(/^@/, "");
|
|
135
|
+
const target = bare.toLowerCase();
|
|
136
|
+
const targetSlug = channelSlug(bare);
|
|
125
137
|
if (!target)
|
|
126
|
-
return
|
|
138
|
+
return [];
|
|
127
139
|
const hits = contacts.filter((c) => {
|
|
128
140
|
if (!c.personUid)
|
|
129
141
|
return false;
|
|
130
142
|
const dn = c.displayName?.trim().toLowerCase();
|
|
131
143
|
return Boolean(dn && (dn === target || (targetSlug && channelSlug(dn) === targetSlug)));
|
|
132
144
|
});
|
|
133
|
-
|
|
134
|
-
|
|
145
|
+
return [...new Map(hits.map((c) => [c.personUid, c])).values()];
|
|
146
|
+
}
|
|
147
|
+
/** One line per ambiguous contact match, for a refusal message. Pure. */
|
|
148
|
+
export function formatContactMatches(matches) {
|
|
149
|
+
return matches
|
|
150
|
+
.map((c) => ` ${c.displayName || "(no name)"}${c.email ? ` <${c.email}>` : ""} ${c.personUid}`)
|
|
151
|
+
.join("\n");
|
|
135
152
|
}
|
|
153
|
+
/** Contacts, or null when the lookup failed (so callers can say so). */
|
|
136
154
|
async function fetchContacts(token) {
|
|
137
155
|
try {
|
|
138
156
|
const res = await vaultApiFetch({ token, path: "/v1/notify/contacts" });
|
|
139
157
|
if (!res.ok)
|
|
140
|
-
return
|
|
158
|
+
return null;
|
|
141
159
|
const data = (await res.json());
|
|
142
|
-
return Array.isArray(data.contacts) ? data.contacts :
|
|
160
|
+
return Array.isArray(data.contacts) ? data.contacts : null;
|
|
143
161
|
}
|
|
144
162
|
catch {
|
|
145
|
-
return
|
|
163
|
+
return null;
|
|
146
164
|
}
|
|
147
165
|
}
|
|
148
166
|
/**
|
|
@@ -427,6 +445,8 @@ export async function resolveDmPeerUid(token, recipient) {
|
|
|
427
445
|
function friendlyDmError(status, code, fallback) {
|
|
428
446
|
if (status === 401)
|
|
429
447
|
return "Not authenticated — run `hq login` and try again.";
|
|
448
|
+
if (code === PERSON_CANNOT_SEND_AGENT_CODE)
|
|
449
|
+
return fallback?.trim() || PERSON_AGENT_AUDIENCE_MESSAGE;
|
|
430
450
|
if (status === 404 || code === "RECIPIENT_NOT_FOUND") {
|
|
431
451
|
return "Recipient not found or not reachable — you can only DM someone you share an active company with.";
|
|
432
452
|
}
|
|
@@ -818,29 +838,47 @@ function explicitAudience(opts) {
|
|
|
818
838
|
}
|
|
819
839
|
return explicit;
|
|
820
840
|
}
|
|
841
|
+
/** An explicit agent/both audience from a person login. */
|
|
842
|
+
export class PersonAudienceError extends Error {
|
|
843
|
+
constructor() {
|
|
844
|
+
super(PERSON_AGENT_AUDIENCE_MESSAGE);
|
|
845
|
+
this.name = "PersonAudienceError";
|
|
846
|
+
}
|
|
847
|
+
}
|
|
821
848
|
/**
|
|
822
849
|
* Resolve the audience for a send. The default comes from the recipient kind
|
|
823
|
-
* (`defaultAudienceFor`
|
|
824
|
-
* `--
|
|
850
|
+
* and the caller (`defaultAudienceFor`: always `human` for a person login);
|
|
851
|
+
* `--for-human` maps to `human`; an explicit `--audience` wins over
|
|
852
|
+
* `--for-human` (with a warning) and is validated. A person login passing
|
|
853
|
+
* `--audience agent|both` throws PersonAudienceError.
|
|
825
854
|
*/
|
|
826
|
-
export function resolveSendAudience(opts, recipientKind, warn = (m) => console.error(chalk.yellow(m))) {
|
|
855
|
+
export function resolveSendAudience(opts, recipientKind, warn = (m) => console.error(chalk.yellow(m)), machineCaller = callerIsMachinePrincipal()) {
|
|
827
856
|
const explicit = explicitAudience(opts);
|
|
857
|
+
if (!callerMayUseAudience(explicit, machineCaller))
|
|
858
|
+
throw new PersonAudienceError();
|
|
828
859
|
if (explicit !== undefined) {
|
|
829
860
|
if (opts.forHuman) {
|
|
830
861
|
warn("Both --audience " + explicit + " and --for-human were passed; using --audience " + explicit + ".");
|
|
831
862
|
}
|
|
832
863
|
return explicit;
|
|
833
864
|
}
|
|
834
|
-
return opts.forHuman ? "human" : defaultAudienceFor(recipientKind);
|
|
865
|
+
return opts.forHuman ? "human" : defaultAudienceFor(recipientKind, machineCaller);
|
|
835
866
|
}
|
|
836
867
|
async function runDmSend(recipient, message, rawOpts) {
|
|
837
868
|
// Validate an explicit --audience before any network call; the default is
|
|
838
869
|
// applied once the recipient kind is known.
|
|
839
870
|
explicitAudience(rawOpts);
|
|
871
|
+
// People never send agent-tagged messages: refuse before any network call.
|
|
872
|
+
const machineCaller = callerIsMachinePrincipal();
|
|
873
|
+
if (!callerMayUseAudience(rawOpts.audience, machineCaller)) {
|
|
874
|
+
console.error(chalk.red(PERSON_AGENT_AUDIENCE_MESSAGE));
|
|
875
|
+
process.exit(1);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
840
878
|
const opts = { ...rawOpts };
|
|
841
879
|
const withAudience = (kind) => ({
|
|
842
880
|
...rawOpts,
|
|
843
|
-
audience: resolveSendAudience(rawOpts, kind),
|
|
881
|
+
audience: resolveSendAudience(rawOpts, kind, undefined, machineCaller),
|
|
844
882
|
});
|
|
845
883
|
// --channel <name> is an explicit channel target; the positional carries the
|
|
846
884
|
// message (recipient slot), e.g. `hq dm --channel vyg-dev "hello"`.
|
|
@@ -874,16 +912,46 @@ async function runDmSend(recipient, message, rawOpts) {
|
|
|
874
912
|
// A bare name or #hash addresses a named DM channel.
|
|
875
913
|
const channelName = parseChannelName(recipient);
|
|
876
914
|
if (channelName !== null) {
|
|
877
|
-
// A bare word
|
|
878
|
-
// exact person and say so; '#name' or --channel always means the
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
915
|
+
// A bare word may name a person or bot as well as a channel. Prefer the
|
|
916
|
+
// exact person/bot and say so; '#name' or --channel always means the
|
|
917
|
+
// channel, '@name' always means a person/bot. Several same-named
|
|
918
|
+
// contacts are ambiguous: refuse with the list rather than fall through
|
|
919
|
+
// to a channel of that name (feedback eba06f59).
|
|
920
|
+
const typed = recipient.trim();
|
|
921
|
+
const explicitPerson = typed.startsWith("@");
|
|
922
|
+
const bare = channelName.replace(/^@/, "");
|
|
923
|
+
let person = null;
|
|
924
|
+
if (!typed.startsWith("#")) {
|
|
925
|
+
const contacts = await fetchContacts(await ensureCognitoToken());
|
|
926
|
+
if (contacts === null) {
|
|
927
|
+
// Without contacts we cannot tell a person/bot from a channel, so a
|
|
928
|
+
// bare name must not guess the channel either.
|
|
929
|
+
console.error(chalk.red(`Could not load your contacts to resolve '${typed}'; nothing was sent. ` +
|
|
930
|
+
`Use their email or prs_/agt_ uid for a person or bot, or '#${bare}' or --channel ${bare} to post to the channel.`));
|
|
931
|
+
process.exit(1);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
const matches = matchContactsByName(contacts, bare);
|
|
936
|
+
if (matches.length > 1) {
|
|
937
|
+
console.error(chalk.red(`'${bare}' matches ${matches.length} people or bots; nothing was sent. Use an email or uid:\n${formatContactMatches(matches)}\n` +
|
|
938
|
+
`Use '#${bare}' or --channel ${bare} for a channel.`));
|
|
939
|
+
process.exit(1);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
person = matches[0] ?? null;
|
|
943
|
+
}
|
|
944
|
+
if (!person && explicitPerson) {
|
|
945
|
+
console.error(chalk.red(`No person or bot named '${bare}' in your contacts; nothing was sent. Use '#${bare}' for a channel.`));
|
|
946
|
+
process.exit(1);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
882
950
|
if (!person?.personUid) {
|
|
883
951
|
await runChannelSend(channelName, message, withAudience("channel"));
|
|
884
952
|
return;
|
|
885
953
|
}
|
|
886
|
-
console.log(chalk.dim(`Resolved '${
|
|
954
|
+
console.log(chalk.dim(`Resolved '${bare}' to person ${person.displayName || person.email || person.personUid} (${person.personUid}). Use '#${bare}' or --channel ${bare} to post to a channel instead.`));
|
|
887
955
|
recipient = person.personUid;
|
|
888
956
|
}
|
|
889
957
|
Object.assign(opts, withAudience(recipientKindOf(recipient)));
|
|
@@ -1492,23 +1560,46 @@ async function runAgentInbox(token, agentUid, opts) {
|
|
|
1492
1560
|
console.error(chalk.red(`GET ${route} → ${res.status}`));
|
|
1493
1561
|
process.exit(1);
|
|
1494
1562
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1563
|
+
// Same guard as `hq agent inbox` (hq-cli #2155): a 200 alone does not
|
|
1564
|
+
// certify the read. A decode failure stays undefined for the classifier;
|
|
1565
|
+
// parser errors and response bytes are never echoed.
|
|
1566
|
+
const classified = classifyAgentInboxBody(await res.json().catch(() => undefined));
|
|
1567
|
+
if (classified.status === "invalid") {
|
|
1568
|
+
console.error(chalk.red(`GET ${route} returned an invalid response; the inbox could not be read, and nothing was acknowledged.`));
|
|
1569
|
+
process.exit(1);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
const partial = classified.status === "partial";
|
|
1573
|
+
if (partial) {
|
|
1574
|
+
console.error(chalk.yellow("The server inbox contained invalid messages; showing the valid ones only, which may be incomplete."));
|
|
1575
|
+
}
|
|
1576
|
+
let items = classified.items.map(redactAgentInboxItem);
|
|
1497
1577
|
if (opts.limit && Number(opts.limit) > 0)
|
|
1498
1578
|
items = items.slice(0, Number(opts.limit));
|
|
1499
1579
|
if (opts.json) {
|
|
1500
1580
|
console.log(JSON.stringify(items, null, 2));
|
|
1501
1581
|
}
|
|
1502
1582
|
else if (items.length === 0) {
|
|
1503
|
-
console.log(chalk.dim(
|
|
1583
|
+
console.log(chalk.dim(partial
|
|
1584
|
+
? "No pending inbox items can be confirmed; the server inbox could not be read completely."
|
|
1585
|
+
: "No pending inbox items."));
|
|
1504
1586
|
}
|
|
1505
1587
|
else {
|
|
1506
1588
|
console.log(chalk.green(`${items.length} pending inbox item${items.length === 1 ? "" : "s"}:`));
|
|
1507
1589
|
for (const item of items)
|
|
1508
1590
|
console.log(`\n${formatAgentInboxItem(item)}`);
|
|
1509
1591
|
}
|
|
1510
|
-
if (opts.markRead) {
|
|
1592
|
+
if (opts.markRead && partial) {
|
|
1593
|
+
// An incomplete read cannot show what --mark-read would hide, so ack nothing.
|
|
1594
|
+
const n = items.length;
|
|
1595
|
+
const ids = items.map((item) => item.id).join(", ");
|
|
1596
|
+
console.error(chalk.yellow(`--mark-read skipped: the inbox read was incomplete, so ${n} ${n === 1 ? "item was" : "items were"} not acked` +
|
|
1597
|
+
`${opts.limit ? " (count reflects --limit)" : ""}` +
|
|
1598
|
+
`${n > 0 ? `: ${ids}. Ack individually with \`hq agent inbox done <id>\`.` : "."}`));
|
|
1599
|
+
}
|
|
1600
|
+
if (opts.markRead && !partial) {
|
|
1511
1601
|
let acked = 0;
|
|
1602
|
+
let failed = 0;
|
|
1512
1603
|
for (const item of items) {
|
|
1513
1604
|
const id = typeof item.messageId === "string" ? item.messageId : typeof item.id === "string" ? item.id : null;
|
|
1514
1605
|
if (!id)
|
|
@@ -1516,9 +1607,13 @@ async function runAgentInbox(token, agentUid, opts) {
|
|
|
1516
1607
|
const ack = await vaultApiFetch({ token, path: `${route}/${encodeURIComponent(id)}/ack`, method: "POST" });
|
|
1517
1608
|
if (ack.ok)
|
|
1518
1609
|
acked += 1;
|
|
1610
|
+
else
|
|
1611
|
+
failed += 1;
|
|
1519
1612
|
}
|
|
1520
1613
|
if (!opts.json && acked > 0)
|
|
1521
1614
|
console.log(chalk.dim(`\nAcked ${acked}.`));
|
|
1615
|
+
if (failed > 0)
|
|
1616
|
+
console.error(chalk.yellow(`${failed} item${failed === 1 ? "" : "s"} could not be acked.`));
|
|
1522
1617
|
}
|
|
1523
1618
|
}
|
|
1524
1619
|
async function runDmInbox(opts) {
|
|
@@ -1729,7 +1824,7 @@ async function runDmChannel(target, opts) {
|
|
|
1729
1824
|
export function registerDmCommand(program) {
|
|
1730
1825
|
const dm = program
|
|
1731
1826
|
.command("dm")
|
|
1732
|
-
.description("Send and read direct messages, and manage connection requests. Messages
|
|
1827
|
+
.description("Send and read direct messages, and manage connection requests. Messages sent from a person login are always tagged for people. When an agent or bot sends, messages to an agent, a channel or a group are tagged for agents and folded away from people by default. Pass --for-human or --audience human to tag a message for people; --audience agent|both is for agents and bots only.");
|
|
1733
1828
|
dm
|
|
1734
1829
|
.command("send [recipient] [message]", { isDefault: true, hidden: true })
|
|
1735
1830
|
.description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @"Jacob Posel") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare "@" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.\n\nCHANNEL THREADS: `hq dm <channel> --thread <eventId> "…"` replies under an existing root (the request carries rootEventId). `--thread last` replies to your most recent top-level message in that channel. Copy a root eventId from `hq dm channel <name>`, which prints each root\'s id and indents replies underneath.')
|
|
@@ -1742,7 +1837,7 @@ export function registerDmCommand(program) {
|
|
|
1742
1837
|
.option("--details-file <path>", "Read the details from a file (1:1 DMs; channel sends also supported)")
|
|
1743
1838
|
.option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
|
|
1744
1839
|
.option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
|
|
1745
|
-
.option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both. Default: human for a person, agent for an agent, a channel or a group. Wins over --for-human.")
|
|
1840
|
+
.option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both. agent and both are for agents and bots only; a person login always sends human. Default for agents and bots: human for a person, agent for an agent, a channel or a group. Wins over --for-human.")
|
|
1746
1841
|
.option("--for-human", "Tag the message for people (alias for --audience human).")
|
|
1747
1842
|
.option("--attach <path>", "Upload a local file and attach it to the message (repeatable, up to 5 files, 25 MB each). 1:1 DMs and named channels; the file lands in the conversation's vault scope and the recipient can open it. Not available for group DMs or scheduled sends.", (value, previous = []) => [...previous, value])
|
|
1748
1843
|
.action(async (recipient, message, opts) => {
|
package/dist/commands/files.d.ts
CHANGED
|
@@ -78,6 +78,7 @@ export declare function registerFilesCommand(program: Command): Command;
|
|
|
78
78
|
* whether it is a folder (`Makefile`, `bin/hq`); resolveBareSharePrefix asks
|
|
79
79
|
* the vault.
|
|
80
80
|
*/
|
|
81
|
+
export declare function isSingleSegmentWildcardPrefix(prefix: string): boolean;
|
|
81
82
|
export declare function looksLikeBareFolderPrefix(prefix: string): boolean;
|
|
82
83
|
/**
|
|
83
84
|
* Refuse a bare prefix that would become a malformed grant if it were
|
package/dist/commands/files.js
CHANGED
|
@@ -164,7 +164,7 @@ export function formatShareSessionError(err) {
|
|
|
164
164
|
export function registerFilesCommand(program) {
|
|
165
165
|
const files = program
|
|
166
166
|
.command("files")
|
|
167
|
-
.description("Manage file access controls in HQ vault. A trailing folder slash (foo/) is a private folder; the quoted glob 'foo/*' shares all children.")
|
|
167
|
+
.description("Manage file access controls in HQ vault. A trailing folder slash (foo/) is a private folder; the quoted glob 'foo/*' shares all children, and a quoted 'clients/*/reports' shares that folder under every client.")
|
|
168
168
|
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
169
169
|
// An exported HQ_API_KEY must never fall back to the caller's Cognito session
|
|
170
170
|
// (that would ignore the key's scope entirely), so every subcommand is gated.
|
|
@@ -193,7 +193,7 @@ export function registerFilesCommand(program) {
|
|
|
193
193
|
});
|
|
194
194
|
files
|
|
195
195
|
.command("share [paths...]")
|
|
196
|
-
.description("Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
|
|
196
|
+
.description("Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*', or a one-folder wildcard such as 'clients/*/reports', so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
|
|
197
197
|
.option("--with <principal>", "Email address, group id, company Outpost id (out_...), or '@all' to share with every active company member")
|
|
198
198
|
.option("--permission <level>", "Permission level (only with --with): read | write")
|
|
199
199
|
.option("--exact", "With --with: grant exactly the object named by <prefix>, even when it looks like a folder (by default a bare folder name such as 'knowledge' is granted as the folder glob 'knowledge/*').")
|
|
@@ -763,6 +763,8 @@ export function registerFilesCommand(program) {
|
|
|
763
763
|
async function warnIfEmptySharePrefix(params) {
|
|
764
764
|
if (isPrivateFolderPattern(params.prefix))
|
|
765
765
|
return;
|
|
766
|
+
if (isSingleSegmentWildcardPrefix(params.prefix))
|
|
767
|
+
return;
|
|
766
768
|
let enabled;
|
|
767
769
|
try {
|
|
768
770
|
enabled = await resolveCompanyFlagGate(params.companyUid, params.token, FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG, { fallback: false }, () => false);
|
|
@@ -879,9 +881,21 @@ async function resolveMentionPersonUid(mention, companySlug) {
|
|
|
879
881
|
* whether it is a folder (`Makefile`, `bin/hq`); resolveBareSharePrefix asks
|
|
880
882
|
* the vault.
|
|
881
883
|
*/
|
|
884
|
+
export function isSingleSegmentWildcardPrefix(prefix) {
|
|
885
|
+
const body = prefix.endsWith("/*") ? prefix.slice(0, -2) : prefix;
|
|
886
|
+
const segments = body.split("/");
|
|
887
|
+
if (segments.some((segment) => segment.length === 0))
|
|
888
|
+
return false;
|
|
889
|
+
if (segments.some((segment) => segment !== "*" && segment.includes("*")))
|
|
890
|
+
return false;
|
|
891
|
+
const wildcards = segments.filter((segment) => segment === "*").length;
|
|
892
|
+
return wildcards === 1 && wildcards < segments.length;
|
|
893
|
+
}
|
|
882
894
|
export function looksLikeBareFolderPrefix(prefix) {
|
|
883
895
|
if (prefix === "*" || prefix.endsWith("/") || prefix.endsWith("/*"))
|
|
884
896
|
return false;
|
|
897
|
+
if (isSingleSegmentWildcardPrefix(prefix))
|
|
898
|
+
return false;
|
|
885
899
|
const last = prefix.slice(prefix.lastIndexOf("/") + 1);
|
|
886
900
|
return last.length > 0 && !last.includes(".");
|
|
887
901
|
}
|
|
@@ -894,8 +908,12 @@ export function malformedBareSharePrefix(prefix) {
|
|
|
894
908
|
if (!looksLikeBareFolderPrefix(prefix))
|
|
895
909
|
return null;
|
|
896
910
|
if (prefix.includes("*")) {
|
|
897
|
-
|
|
898
|
-
|
|
911
|
+
const reason = prefix.split("/").some((segment) => segment !== "*" && segment.includes("*"))
|
|
912
|
+
? "has a '*' inside a path segment"
|
|
913
|
+
: "has more than one '*' folder";
|
|
914
|
+
return (`'${prefix}' ${reason}. A '*' must be a whole folder name: once per path ` +
|
|
915
|
+
`(for example 'clients/*/reports'), as a trailing '/*' (for example 'knowledge/*'), ` +
|
|
916
|
+
`or '*' alone (--full).`);
|
|
899
917
|
}
|
|
900
918
|
if (prefix.split("/").some((segment) => segment.length === 0)) {
|
|
901
919
|
return `'${prefix}' has an empty path segment. Pass a company-relative path such as 'knowledge'.`;
|
|
@@ -161,7 +161,10 @@ export interface InstallInput {
|
|
|
161
161
|
placement: "header";
|
|
162
162
|
header: string;
|
|
163
163
|
};
|
|
164
|
+
scope?: ConnectScope;
|
|
164
165
|
}
|
|
166
|
+
export type ConnectScope = "personal" | "company";
|
|
167
|
+
export declare function scopeUnavailableMessage(scope: ConnectScope): string;
|
|
165
168
|
export declare function installIntegration(token: string, companyUid: string, input: InstallInput): Promise<InstallResult>;
|
|
166
169
|
export declare function uninstallIntegration(token: string, companyUid: string, target: {
|
|
167
170
|
installationId: string;
|
|
@@ -227,6 +230,7 @@ export interface OAuthStartInput {
|
|
|
227
230
|
* direct `mcpUrl` path.
|
|
228
231
|
*/
|
|
229
232
|
scopes?: string[];
|
|
233
|
+
scope?: ConnectScope;
|
|
230
234
|
}
|
|
231
235
|
export declare function startOAuth(token: string, companyUid: string, input: OAuthStartInput): Promise<OAuthStartResult>;
|
|
232
236
|
export declare function completeOAuth(token: string, companyUid: string, input: {
|
|
@@ -53,6 +53,22 @@ export async function discoverDocs(token, companyUid, docsUrl) {
|
|
|
53
53
|
await raiseForResponse(res, "Failed to read that documentation page");
|
|
54
54
|
return (await res.json());
|
|
55
55
|
}
|
|
56
|
+
export function scopeUnavailableMessage(scope) {
|
|
57
|
+
const flag = scope === "personal" ? "--personal" : "--company-owned";
|
|
58
|
+
return ("Personal and company integrations aren't turned on for this company yet. " +
|
|
59
|
+
`Run the command again without ${flag} to connect the usual way.`);
|
|
60
|
+
}
|
|
61
|
+
async function raiseForConnectResponse(res, fallback, scope) {
|
|
62
|
+
try {
|
|
63
|
+
return await raiseForResponse(res, fallback);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (scope && error instanceof IntegrationsCliError && error.status === 404 && !error.code) {
|
|
67
|
+
throw new IntegrationsCliError(scopeUnavailableMessage(scope), { expected: true, status: 404 });
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
56
72
|
export async function installIntegration(token, companyUid, input) {
|
|
57
73
|
const res = await vaultApiFetch({
|
|
58
74
|
token,
|
|
@@ -62,7 +78,7 @@ export async function installIntegration(token, companyUid, input) {
|
|
|
62
78
|
});
|
|
63
79
|
if (!res.ok) {
|
|
64
80
|
try {
|
|
65
|
-
await
|
|
81
|
+
await raiseForConnectResponse(res, "Failed to connect the app", input.scope);
|
|
66
82
|
}
|
|
67
83
|
catch (error) {
|
|
68
84
|
if (error instanceof IntegrationsCliError &&
|
|
@@ -106,7 +122,7 @@ export async function startOAuth(token, companyUid, input) {
|
|
|
106
122
|
body: { companyUid, ...input },
|
|
107
123
|
});
|
|
108
124
|
if (!res.ok)
|
|
109
|
-
await
|
|
125
|
+
await raiseForConnectResponse(res, "Failed to start sign-in", input.scope);
|
|
110
126
|
return (await res.json());
|
|
111
127
|
}
|
|
112
128
|
export async function completeOAuth(token, companyUid, input) {
|
|
@@ -27,7 +27,7 @@ import { type ResidentCommandLeaseOptions } from "../utils/resident-package-prel
|
|
|
27
27
|
import { consoleOrigin } from "../utils/console-origin.js";
|
|
28
28
|
export { consoleOrigin };
|
|
29
29
|
import type { AdminConnection } from "./integrations-core.js";
|
|
30
|
-
import { type InstallInput } from "./integrations-api.js";
|
|
30
|
+
import { type ConnectScope, type InstallInput } from "./integrations-api.js";
|
|
31
31
|
interface ConnectOptions {
|
|
32
32
|
company?: string;
|
|
33
33
|
entryId?: string;
|
|
@@ -54,7 +54,10 @@ interface ConnectOptions {
|
|
|
54
54
|
* to a person and check `hq integrations list` afterwards.
|
|
55
55
|
*/
|
|
56
56
|
linkOnly?: boolean;
|
|
57
|
+
personal?: boolean;
|
|
58
|
+
companyOwned?: boolean;
|
|
57
59
|
}
|
|
60
|
+
export declare function connectScopeFromOptions(opts: Pick<ConnectOptions, "personal" | "companyOwned">): ConnectScope | undefined;
|
|
58
61
|
/**
|
|
59
62
|
* Validate `--auth` against the supported enum, rejecting an unknown value
|
|
60
63
|
* before anything is installed. A finite provider/runtime option must never be
|
|
@@ -69,7 +72,7 @@ export declare function shellQuote(value: string): string;
|
|
|
69
72
|
/** Everything the connect verbs need to name one app to hq-pro. */
|
|
70
73
|
interface ConnectTarget {
|
|
71
74
|
/** Install/OAuth identity fields, exactly as hq-pro expects them. */
|
|
72
|
-
ref: Pick<InstallInput, "domain" | "query" | "catalogEntryId" | "discoveryReceiptId" | "mcpUrl" | "provider" | "displayName" | "docsUrl">;
|
|
75
|
+
ref: Pick<InstallInput, "domain" | "query" | "catalogEntryId" | "discoveryReceiptId" | "mcpUrl" | "provider" | "displayName" | "docsUrl" | "scope">;
|
|
73
76
|
/** Best-known auth mode before hq-pro is asked; `undefined` = unknown. */
|
|
74
77
|
authClass?: "none" | "key" | "oauth";
|
|
75
78
|
/** Human label for prompts and success lines. */
|
|
@@ -109,6 +112,10 @@ export declare function bringYourOwnClientHandoff(target: ConnectTarget, opts: C
|
|
|
109
112
|
* built, so the hint always names the URL Google will actually be sent.
|
|
110
113
|
*/
|
|
111
114
|
export declare function googleClientTypeHint(clientId: string, authorizationUrl: string): string | null;
|
|
112
|
-
export
|
|
115
|
+
export interface ConnectOwnership {
|
|
116
|
+
scope: ConnectScope;
|
|
117
|
+
viewerPersonUid?: string;
|
|
118
|
+
}
|
|
119
|
+
export declare function findAlreadyConnected(connections: readonly AdminConnection[], app: string, ownership?: ConnectOwnership): AdminConnection | undefined;
|
|
113
120
|
export declare function registerConnectCommands(integrations: Command): void;
|
|
114
121
|
//# sourceMappingURL=integrations-connect.d.ts.map
|
|
@@ -31,7 +31,7 @@ import { consoleOrigin } from "../utils/console-origin.js";
|
|
|
31
31
|
export { consoleOrigin };
|
|
32
32
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
33
33
|
import { redactSensitiveUrl, redactUrlsInText } from "../utils/redact-url.js";
|
|
34
|
-
import { IntegrationsCliError, bareProvider, connectionDomain, fetchAdminSurface, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
|
|
34
|
+
import { IntegrationsCliError, bareProvider, connectionAccountLabel, connectionDomain, connectionOwnerLabel, fetchAdminSurface, printJson, revokedConnectionDetails, resolveConnection, } from "./integrations-core.js";
|
|
35
35
|
import { completeOAuth, discoverDocs, installIntegration, listCatalog, pullBlueprint, startOAuth, } from "./integrations-api.js";
|
|
36
36
|
import { OAUTH_LOOPBACK_TIMEOUT_CODE, startLoopbackListener, } from "./integrations-oauth.js";
|
|
37
37
|
/** hq-pro's machine code for "this endpoint needs a browser sign-in". */
|
|
@@ -103,6 +103,19 @@ const BRING_YOUR_OWN_CLIENT_CODES = new Set([
|
|
|
103
103
|
"OAUTH_REGISTRATION_UNSUPPORTED",
|
|
104
104
|
"CLIENT_REGISTRATION_REFUSED",
|
|
105
105
|
]);
|
|
106
|
+
export function connectScopeFromOptions(opts) {
|
|
107
|
+
if (opts.personal && opts.companyOwned) {
|
|
108
|
+
throw new IntegrationsCliError("--personal and --company-owned cannot be used together; pick one.", { expected: true });
|
|
109
|
+
}
|
|
110
|
+
if (opts.personal)
|
|
111
|
+
return "personal";
|
|
112
|
+
if (opts.companyOwned)
|
|
113
|
+
return "company";
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
function scopeFlag(scope) {
|
|
117
|
+
return scope === "personal" ? "--personal" : "--company-owned";
|
|
118
|
+
}
|
|
106
119
|
/**
|
|
107
120
|
* Server-side limits, restated here so a typo fails in the terminal with a
|
|
108
121
|
* sentence rather than as an opaque 400 three network hops away. Kept in step
|
|
@@ -758,6 +771,10 @@ async function consoleHandoff(target, opts) {
|
|
|
758
771
|
if (opts.scopes) {
|
|
759
772
|
console.error(chalk.yellow(`Note: --scopes cannot be carried into a console sign-in, so this connect will use ${target.label}'s default permissions.`));
|
|
760
773
|
}
|
|
774
|
+
const scope = connectScopeFromOptions(opts);
|
|
775
|
+
if (scope) {
|
|
776
|
+
console.error(chalk.yellow(`Note: ${scopeFlag(scope)} cannot be carried into a console sign-in; choose who owns ${target.label} on the console page.`));
|
|
777
|
+
}
|
|
761
778
|
if (url && opts.browser !== false)
|
|
762
779
|
await open(url).catch((error) => warnBrowserOpenFailure(error));
|
|
763
780
|
console.error(chalk.dim("When the browser says it connected, run `hq integrations list` to confirm."));
|
|
@@ -820,6 +837,9 @@ function byoClientCommand(target, opts) {
|
|
|
820
837
|
// caller a line that reproduces the failure they are recovering from.
|
|
821
838
|
if (opts.scopes)
|
|
822
839
|
parts.push(`--scopes ${shellQuote(opts.scopes)}`);
|
|
840
|
+
const scope = connectScopeFromOptions(opts);
|
|
841
|
+
if (scope)
|
|
842
|
+
parts.push(scopeFlag(scope));
|
|
823
843
|
return parts.join(" ");
|
|
824
844
|
}
|
|
825
845
|
/**
|
|
@@ -1035,6 +1055,7 @@ async function targetForAccessKeyConnect(token, companyUid, target, opts) {
|
|
|
1035
1055
|
return {
|
|
1036
1056
|
ref: {
|
|
1037
1057
|
mcpUrl,
|
|
1058
|
+
...(target.ref.scope ? { scope: target.ref.scope } : {}),
|
|
1038
1059
|
...(opts.provider ? { provider: opts.provider } : blueprint.provider ? { provider: blueprint.provider } : {}),
|
|
1039
1060
|
...(opts.displayName
|
|
1040
1061
|
? { displayName: opts.displayName }
|
|
@@ -1079,7 +1100,9 @@ function reportInstall(result, opts, expectedRevivedConnectionId) {
|
|
|
1079
1100
|
* URL: hq-pro recognizes the domain and revives the revoked row in place.
|
|
1080
1101
|
*/
|
|
1081
1102
|
async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
|
|
1082
|
-
const
|
|
1103
|
+
const resolved = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
|
|
1104
|
+
const scope = connectScopeFromOptions(opts);
|
|
1105
|
+
const target = scope ? { ...resolved, ref: { ...resolved.ref, scope } } : resolved;
|
|
1083
1106
|
const authMode = opts.authHeader
|
|
1084
1107
|
? "key"
|
|
1085
1108
|
: opts.auth ?? target.authClass;
|
|
@@ -1205,7 +1228,16 @@ function isSettledInstallation(connection) {
|
|
|
1205
1228
|
const status = connection.installation?.status?.trim().toLowerCase();
|
|
1206
1229
|
return status === undefined || status === "" || status === "installed";
|
|
1207
1230
|
}
|
|
1208
|
-
|
|
1231
|
+
function hasRequestedOwnership(connection, ownership) {
|
|
1232
|
+
const ownerType = connection.ownerType;
|
|
1233
|
+
if (ownership.scope === "personal") {
|
|
1234
|
+
return (ownerType === "person" &&
|
|
1235
|
+
Boolean(ownership.viewerPersonUid) &&
|
|
1236
|
+
connection.ownerId === ownership.viewerPersonUid);
|
|
1237
|
+
}
|
|
1238
|
+
return ownerType === undefined || ownerType === "company" || ownerType === "legacy";
|
|
1239
|
+
}
|
|
1240
|
+
export function findAlreadyConnected(connections, app, ownership) {
|
|
1209
1241
|
const want = app.trim().toLowerCase();
|
|
1210
1242
|
if (want === "")
|
|
1211
1243
|
return undefined;
|
|
@@ -1215,6 +1247,8 @@ export function findAlreadyConnected(connections, app) {
|
|
|
1215
1247
|
return false;
|
|
1216
1248
|
if (!isSettledInstallation(connection))
|
|
1217
1249
|
return false;
|
|
1250
|
+
if (ownership && !hasRequestedOwnership(connection, ownership))
|
|
1251
|
+
return false;
|
|
1218
1252
|
const domain = (connectionDomain(connection) ?? "").toLowerCase();
|
|
1219
1253
|
if (domain === want || domain.endsWith(`.${want}`))
|
|
1220
1254
|
return true;
|
|
@@ -1254,22 +1288,31 @@ async function refuseIfAlreadyConnected(token, companyUid, app, opts) {
|
|
|
1254
1288
|
// the deliberate "yes, add another one" escape hatch.
|
|
1255
1289
|
if (!app || opts.force || opts.mcpUrl || opts.docsUrl || opts.entryId)
|
|
1256
1290
|
return false;
|
|
1291
|
+
const requestedScope = connectScopeFromOptions(opts);
|
|
1257
1292
|
let connections;
|
|
1258
1293
|
let viewer;
|
|
1294
|
+
let personalConnect;
|
|
1259
1295
|
try {
|
|
1260
1296
|
const surface = await fetchAdminSurface(token, companyUid);
|
|
1261
1297
|
connections = surface.connections ?? [];
|
|
1262
1298
|
viewer = surface.viewer;
|
|
1299
|
+
personalConnect = surface.capabilities?.personalConnect === true;
|
|
1263
1300
|
}
|
|
1264
1301
|
catch {
|
|
1265
1302
|
// Never let this convenience check block a connect. If the list cannot be
|
|
1266
1303
|
// read, fall through and let the real flow report whatever is wrong.
|
|
1267
1304
|
return false;
|
|
1268
1305
|
}
|
|
1269
|
-
const
|
|
1306
|
+
const scope = requestedScope ?? (personalConnect ? "personal" : undefined);
|
|
1307
|
+
const ownership = scope
|
|
1308
|
+
? { scope, ...(viewer?.personUid ? { viewerPersonUid: viewer.personUid } : {}) }
|
|
1309
|
+
: undefined;
|
|
1310
|
+
const existing = findAlreadyConnected(connections, app, ownership);
|
|
1270
1311
|
if (!existing)
|
|
1271
1312
|
return false;
|
|
1272
1313
|
const slug = bareProvider(existing.provider);
|
|
1314
|
+
const owner = connectionOwnerLabel(existing);
|
|
1315
|
+
const account = connectionAccountLabel(existing);
|
|
1273
1316
|
if (opts.json) {
|
|
1274
1317
|
printJson({
|
|
1275
1318
|
ok: true,
|
|
@@ -1277,11 +1320,14 @@ async function refuseIfAlreadyConnected(token, companyUid, app, opts) {
|
|
|
1277
1320
|
provider: slug,
|
|
1278
1321
|
connection: existing.id,
|
|
1279
1322
|
...(connectionDomain(existing) ? { domain: connectionDomain(existing) } : {}),
|
|
1323
|
+
...(existing.ownerType ? { ownerType: existing.ownerType } : {}),
|
|
1324
|
+
...(existing.accountLabel ? { accountLabel: existing.accountLabel } : {}),
|
|
1280
1325
|
});
|
|
1281
1326
|
return true;
|
|
1282
1327
|
}
|
|
1283
1328
|
const label = redactUrlsInText(existing.installation?.displayName ?? slug);
|
|
1284
|
-
|
|
1329
|
+
const detail = [owner, account].filter(Boolean).join(", ");
|
|
1330
|
+
console.log(chalk.green(`${label} is already connected${detail ? ` (${detail})` : ""}.`));
|
|
1285
1331
|
if (connectedBySomeoneElse(existing, viewer)) {
|
|
1286
1332
|
// Members may add apps and reconnect their own, but replacing a connection
|
|
1287
1333
|
// someone else created stays with an owner or admin: the server refuses
|
|
@@ -1291,7 +1337,7 @@ async function refuseIfAlreadyConnected(token, companyUid, app, opts) {
|
|
|
1291
1337
|
return true;
|
|
1292
1338
|
}
|
|
1293
1339
|
console.log(chalk.dim(` Sign in again: hq integrations reconnect --provider ${slug}\n` +
|
|
1294
|
-
` Add a second: hq integrations connect ${shellQuote(app)} --force`));
|
|
1340
|
+
` Add a second: hq integrations connect ${shellQuote(app)}${requestedScope ? ` ${scopeFlag(requestedScope)}` : ""} --force`));
|
|
1295
1341
|
return true;
|
|
1296
1342
|
}
|
|
1297
1343
|
export function registerConnectCommands(integrations) {
|
|
@@ -1425,8 +1471,11 @@ export function registerConnectCommands(integrations) {
|
|
|
1425
1471
|
.option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
|
|
1426
1472
|
.option("--link-only", "Print the sign-in link and exit without waiting; the sign-in finishes on the HQ console")
|
|
1427
1473
|
.option("--force", "Connect again even if this app is already connected")
|
|
1474
|
+
.option("--personal", "Connect it as your own personal connection (not --company <slug>, which picks the company)")
|
|
1475
|
+
.option("--company-owned", "Connect it as a connection the whole company owns (needs permission to add company apps)")
|
|
1428
1476
|
.option("--json", "Machine-readable output")
|
|
1429
1477
|
.action(async (app, opts) => {
|
|
1478
|
+
connectScopeFromOptions(opts);
|
|
1430
1479
|
assertAuthMode(opts.auth);
|
|
1431
1480
|
assertAuthHeader(opts.authHeader);
|
|
1432
1481
|
if (opts.authHeader && opts.auth !== undefined && opts.auth !== "key") {
|
|
@@ -1453,6 +1502,8 @@ export function registerConnectCommands(integrations) {
|
|
|
1453
1502
|
.option("--no-browser", "Print the sign-in URL instead of opening a browser")
|
|
1454
1503
|
.option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
|
|
1455
1504
|
.option("--connect", "For a revoked row, run `connect <domain>` to re-add and revive it")
|
|
1505
|
+
.option("--personal", "With --connect: re-add it as your own personal connection")
|
|
1506
|
+
.option("--company-owned", "With --connect: re-add it as a connection the whole company owns")
|
|
1456
1507
|
.option("--json", "Machine-readable output")
|
|
1457
1508
|
.action(async (app, opts) => {
|
|
1458
1509
|
// Same validation as `connect`. Without it a typo like `--auth oauth2`
|
|
@@ -1460,11 +1511,19 @@ export function registerConnectCommands(integrations) {
|
|
|
1460
1511
|
// silently reinstalls with auto-detection — the caller believes they
|
|
1461
1512
|
// forced a mode that was never applied.
|
|
1462
1513
|
assertAuthMode(opts.auth);
|
|
1514
|
+
const scope = connectScopeFromOptions(opts);
|
|
1515
|
+
if (scope && !opts.connect) {
|
|
1516
|
+
throw new IntegrationsCliError(`${scopeFlag(scope)} only applies with --connect, when a revoked app is added again.`, { expected: true });
|
|
1517
|
+
}
|
|
1463
1518
|
const token = await ensureCognitoIdToken();
|
|
1464
1519
|
const companyUid = await getCompanyUid(token, opts.company);
|
|
1465
1520
|
const connection = await resolveConnection(token, companyUid, app, opts, {
|
|
1466
1521
|
allowSingleRevoked: Boolean(opts.connect),
|
|
1467
1522
|
});
|
|
1523
|
+
if (scope && connection.status !== "revoked") {
|
|
1524
|
+
throw new IntegrationsCliError(`${scopeFlag(scope)} only applies when --connect adds a revoked app again; this one is ${connection.status}. ` +
|
|
1525
|
+
"A reconnect keeps the connection's current owner.", { expected: true });
|
|
1526
|
+
}
|
|
1468
1527
|
if (connection.status === "revoked") {
|
|
1469
1528
|
const details = revokedConnectionDetails(connection, opts.company);
|
|
1470
1529
|
if (!opts.connect) {
|
|
@@ -1492,6 +1551,11 @@ export function registerConnectCommands(integrations) {
|
|
|
1492
1551
|
...(connection.installation?.displayName
|
|
1493
1552
|
? { displayName: connection.installation.displayName }
|
|
1494
1553
|
: {}),
|
|
1554
|
+
...(connection.ownerType === "company"
|
|
1555
|
+
? { scope: "company" }
|
|
1556
|
+
: connection.ownerType === "person"
|
|
1557
|
+
? { scope: "personal" }
|
|
1558
|
+
: {}),
|
|
1495
1559
|
},
|
|
1496
1560
|
...(opts.auth ? { authClass: opts.auth } : {}),
|
|
1497
1561
|
label: connection.installation?.displayName ?? bareProvider(connection.provider),
|