@indigoai-us/hq-cli 5.122.1 → 5.123.0

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +46 -0
  3. package/dist/command-catalog.generated.d.ts +6 -3
  4. package/dist/command-catalog.generated.js +7 -3
  5. package/dist/commands/bot.d.ts +3 -0
  6. package/dist/commands/bot.js +14 -2
  7. package/dist/commands/dm.d.ts +54 -1
  8. package/dist/commands/dm.js +204 -8
  9. package/dist/commands/whoami.d.ts +2 -2
  10. package/dist/commands/whoami.js +18 -19
  11. package/dist/lib/billing/plan-lock.d.ts +64 -69
  12. package/dist/lib/billing/plan-lock.js +230 -161
  13. package/dist/lib/bot/api.d.ts +4 -1
  14. package/dist/lib/bot/api.js +20 -2
  15. package/dist/lib/bot/daemon.d.ts +11 -1
  16. package/dist/lib/bot/daemon.js +44 -2
  17. package/dist/lib/bot/run.d.ts +19 -1
  18. package/dist/lib/bot/run.js +51 -6
  19. package/dist/lib/bot/status.d.ts +5 -0
  20. package/dist/lib/doctor/checks/bots.d.ts +10 -0
  21. package/dist/lib/doctor/checks/bots.js +149 -0
  22. package/dist/lib/doctor/registry.js +4 -0
  23. package/dist/lib/mesh/api.js +96 -28
  24. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
  25. package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
  26. package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
  27. package/dist/lib/mesh/live/daemon/credentials.js +20 -4
  28. package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
  29. package/dist/lib/mesh/live/daemon/doctor.js +12 -0
  30. package/dist/lib/mesh/live/daemon/install.d.ts +7 -0
  31. package/dist/lib/mesh/live/daemon/install.js +17 -1
  32. package/dist/lib/mesh/live/daemon/run.js +6 -1
  33. package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
  34. package/dist/utils/cli-telemetry.d.ts +21 -0
  35. package/dist/utils/cli-telemetry.js +77 -1
  36. package/package.json +3 -2
@@ -49,6 +49,11 @@ export interface PresenceCompany {
49
49
  companyUid: string;
50
50
  presenceTopic: string;
51
51
  }
52
+ export interface DroppedPresenceCompany {
53
+ companyUid: string;
54
+ /** Server-supplied cause when available; otherwise a deliberately neutral label. */
55
+ reason: string;
56
+ }
52
57
  export interface Contract3Bundle {
53
58
  contractVersion: 3;
54
59
  credentials: IotCredentials;
@@ -58,7 +63,7 @@ export interface Contract3Bundle {
58
63
  actorUid: string;
59
64
  actorType: "human" | "agent";
60
65
  companies: PresenceCompany[];
61
- droppedCompanies: string[];
66
+ droppedCompanies: DroppedPresenceCompany[];
62
67
  expiresAt: string;
63
68
  }
64
69
  export type CredentialsFetcher = () => Promise<Contract3Bundle>;
@@ -229,9 +229,25 @@ export function normalizeContract3Bundle(raw) {
229
229
  if (!expiresAt) {
230
230
  throw new Error("contract-3 vend missing expiresAt");
231
231
  }
232
- const dropped = Array.isArray(r.droppedCompanies)
233
- ? r.droppedCompanies.filter((x) => typeof x === "string")
234
- : [];
232
+ const droppedCompanies = [];
233
+ for (const item of Array.isArray(r.droppedCompanies) ? r.droppedCompanies : []) {
234
+ if (typeof item === "string" && item.trim()) {
235
+ droppedCompanies.push({ companyUid: item, reason: "unknown" });
236
+ continue;
237
+ }
238
+ if (!item || typeof item !== "object" || Array.isArray(item))
239
+ continue;
240
+ const row = item;
241
+ const companyUid = typeof row.companyUid === "string" ? row.companyUid.trim() : "";
242
+ if (!companyUid)
243
+ continue;
244
+ droppedCompanies.push({
245
+ companyUid,
246
+ reason: typeof row.reason === "string" && row.reason.trim()
247
+ ? row.reason.trim()
248
+ : "not included",
249
+ });
250
+ }
235
251
  return {
236
252
  contractVersion: 3,
237
253
  credentials: {
@@ -246,7 +262,7 @@ export function normalizeContract3Bundle(raw) {
246
262
  actorUid,
247
263
  actorType,
248
264
  companies,
249
- droppedCompanies: dropped,
265
+ droppedCompanies,
250
266
  expiresAt,
251
267
  };
252
268
  }
@@ -32,6 +32,8 @@ export interface DaemonDoctorReport {
32
32
  actorKind: CognitoActorKind;
33
33
  /** Credential vend refused (disabled / unsupported / 403). */
34
34
  presenceRefusal?: DaemonStateFile["presenceRefusal"];
35
+ /** Companies the current presence credential did not include. */
36
+ presenceDroppedCompanies: NonNullable<DaemonStateFile["presenceDroppedCompanies"]>;
35
37
  /** Pending events awaiting a retry drain (POST /v1/mesh/events). */
36
38
  emitRetryDepth: number;
37
39
  /** Last successful direct-emit POST time. */
@@ -105,6 +105,10 @@ export function collectDaemonDoctor(deps = {}) {
105
105
  `(spool=${unattributedEvents.spool} held=${unattributedEvents.held} dead-letter=${unattributedEvents.deadLetter})`);
106
106
  }
107
107
  const presenceRefusal = state?.presenceRefusal ?? null;
108
+ const presenceDroppedCompanies = state?.presenceDroppedCompanies ?? [];
109
+ if (presenceDroppedCompanies.length > 0) {
110
+ unhealthyReasons.push(`presence unavailable for ${presenceDroppedCompanies.length} companies`);
111
+ }
108
112
  if (presenceRefusal) {
109
113
  unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
110
114
  }
@@ -132,6 +136,7 @@ export function collectDaemonDoctor(deps = {}) {
132
136
  tokenSource: auth.tokenSource,
133
137
  actorKind: auth.actorKind,
134
138
  presenceRefusal,
139
+ presenceDroppedCompanies,
135
140
  emitMode,
136
141
  subscribed,
137
142
  emitRetryDepth: emitRetry,
@@ -174,6 +179,13 @@ export function formatDaemonDoctor(report) {
174
179
  if (report.presenceRefusal) {
175
180
  lines.push(`presence refusal: ${report.presenceRefusal.code} (HTTP ${report.presenceRefusal.status}); next retry at ${report.presenceRefusal.nextRetryAt}`);
176
181
  }
182
+ if (report.presenceDroppedCompanies.length > 0) {
183
+ lines.push(`presence not published for ${report.presenceDroppedCompanies.length} companies: ` +
184
+ report.presenceDroppedCompanies
185
+ .map((company) => `${company.companyUid} (${company.reason})`)
186
+ .join(", ") +
187
+ `; presence credential holds ${report.companiesOnline.length} companies; these were not included`);
188
+ }
177
189
  if (report.unhealthy) {
178
190
  lines.push(`health: UNHEALTHY`);
179
191
  for (const reason of report.unhealthyReasons) {
@@ -34,6 +34,13 @@ export interface InstallResult {
34
34
  message: string;
35
35
  }
36
36
  export declare function detectPlatform(platform?: NodeJS.Platform): DaemonInstallPlatform;
37
+ /**
38
+ * HQ updates moved the CLI from toolchain/node/bin/hq to
39
+ * toolchain/npm-global/bin/hq. A LaunchAgent that still names the old path
40
+ * dies with MODULE_NOT_FOUND on the next restart. Prefer the live npm-global
41
+ * binary when both exist.
42
+ */
43
+ export declare function preferStableHqBinary(entry: string): string;
37
44
  /** Resolve the hq binary the current shell uses (argv[1] or PATH). */
38
45
  export declare function resolveHqBinary(argv?: string[], env?: NodeJS.ProcessEnv): string;
39
46
  export declare function resolveNodeBinary(execPath?: string, env?: NodeJS.ProcessEnv): string;
@@ -21,11 +21,27 @@ export function detectPlatform(platform = process.platform) {
21
21
  return "linux";
22
22
  return "other";
23
23
  }
24
+ /**
25
+ * HQ updates moved the CLI from toolchain/node/bin/hq to
26
+ * toolchain/npm-global/bin/hq. A LaunchAgent that still names the old path
27
+ * dies with MODULE_NOT_FOUND on the next restart. Prefer the live npm-global
28
+ * binary when both exist.
29
+ */
30
+ export function preferStableHqBinary(entry) {
31
+ const stale = `${path.sep}toolchain${path.sep}node${path.sep}bin${path.sep}`;
32
+ const stable = `${path.sep}toolchain${path.sep}npm-global${path.sep}bin${path.sep}`;
33
+ if (!entry.includes(stale))
34
+ return entry;
35
+ const candidate = entry.replace(stale, stable);
36
+ if (candidate !== entry && fs.existsSync(candidate))
37
+ return path.resolve(candidate);
38
+ return entry;
39
+ }
24
40
  /** Resolve the hq binary the current shell uses (argv[1] or PATH). */
25
41
  export function resolveHqBinary(argv = process.argv, env = process.env) {
26
42
  const entry = argv[1];
27
43
  if (entry && fs.existsSync(entry)) {
28
- return path.resolve(entry);
44
+ return preferStableHqBinary(path.resolve(entry));
29
45
  }
30
46
  const pathEnv = env.PATH ?? "";
31
47
  for (const dir of pathEnv.split(path.delimiter)) {
@@ -162,7 +162,7 @@ export async function runMeshDaemon(deps = {}) {
162
162
  });
163
163
  let presence = null;
164
164
  if (deps.enablePresence !== false) {
165
- const fetchCredentials = deps.fetchCredentials ??
165
+ const fetchCredentialsRaw = deps.fetchCredentials ??
166
166
  (async () => {
167
167
  const t = await getToken();
168
168
  return createContract3Fetcher({
@@ -171,6 +171,11 @@ export async function runMeshDaemon(deps = {}) {
171
171
  env,
172
172
  })();
173
173
  });
174
+ const fetchCredentials = async () => {
175
+ const bundle = await fetchCredentialsRaw();
176
+ patchDaemonState(dir, { presenceDroppedCompanies: bundle.droppedCompanies }, now);
177
+ return bundle;
178
+ };
174
179
  const presenceOpts = {
175
180
  fetchCredentials,
176
181
  mqttConnect: deps.mqttConnect,
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { MqttConnectionState } from "./presence.js";
5
5
  import type { FlushSummary } from "../flush.js";
6
+ import type { DroppedPresenceCompany } from "./credentials.js";
6
7
  export interface PresenceRefusalState {
7
8
  code: string;
8
9
  status: number;
@@ -14,6 +15,8 @@ export interface DaemonStateFile {
14
15
  startedAt: string;
15
16
  mqttState: MqttConnectionState;
16
17
  companiesOnline: string[];
18
+ /** Companies omitted from the current presence credential bundle. */
19
+ presenceDroppedCompanies?: DroppedPresenceCompany[];
17
20
  actorUid?: string;
18
21
  lastFlushAt?: string;
19
22
  lastFlushResult?: FlushSummary & {
@@ -1,4 +1,25 @@
1
1
  export declare const TELEMETRY_TIMEOUT_MS = 1200;
2
+ /**
3
+ * Exact stderr line printed once on the first signed-in TTY run that is not
4
+ * `--json`. Wording matches the event body: CLI version + timestamp only.
5
+ */
6
+ export declare const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
7
+ /**
8
+ * `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
9
+ * CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
10
+ */
11
+ export declare function isCliTelemetryDisabled(env?: NodeJS.ProcessEnv): boolean;
12
+ export declare function telemetryDisclosureMarkerPath(): string;
13
+ /**
14
+ * Print the one-line disclosure at most once per machine. Returns whether
15
+ * the notice has been shown (this run or a previous one). `--json` and
16
+ * non-TTY stdout skip the print and do not record the marker.
17
+ */
18
+ export declare function maybeDiscloseCliTelemetry(stderr?: {
19
+ write: (chunk: string) => unknown;
20
+ }, stdout?: {
21
+ isTTY?: boolean;
22
+ }, argv?: readonly string[]): boolean;
2
23
  /**
3
24
  * Emit the authenticated CLI session signal using an already-cached token.
4
25
  * This deliberately never refreshes a session or opens a browser.
@@ -1,8 +1,80 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
1
4
  import { CLI_VERSION } from "../cli-version.js";
2
5
  import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
3
6
  import { vaultApiFetch } from "./vault-api.js";
4
7
  export const TELEMETRY_TIMEOUT_MS = 1_200;
8
+ /**
9
+ * Exact stderr line printed once on the first signed-in TTY run that is not
10
+ * `--json`. Wording matches the event body: CLI version + timestamp only.
11
+ */
12
+ export const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
13
+ const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "off"]);
14
+ const DISCLOSURE_MARKER = "cli-telemetry-disclosed";
5
15
  let cliSessionStartedPromise;
16
+ /**
17
+ * `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
18
+ * CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
19
+ */
20
+ export function isCliTelemetryDisabled(env = process.env) {
21
+ const raw = env.HQ_TELEMETRY?.trim().toLowerCase();
22
+ if (!raw)
23
+ return false;
24
+ return TELEMETRY_DISABLED_VALUES.has(raw);
25
+ }
26
+ function hqConfigDir() {
27
+ const home = process.env.HOME?.trim() || os.homedir();
28
+ return path.join(home, ".hq");
29
+ }
30
+ export function telemetryDisclosureMarkerPath() {
31
+ return path.join(hqConfigDir(), DISCLOSURE_MARKER);
32
+ }
33
+ function wantsJsonOutput(argv = process.argv) {
34
+ return argv.includes("--json");
35
+ }
36
+ function canPrintDisclosure(stdout = process.stdout, argv = process.argv) {
37
+ return Boolean(stdout.isTTY) && !wantsJsonOutput(argv);
38
+ }
39
+ function hasShownDisclosure() {
40
+ try {
41
+ return fs.existsSync(telemetryDisclosureMarkerPath());
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function recordDisclosureShown() {
48
+ try {
49
+ const dir = hqConfigDir();
50
+ // Do not create ~/.hq. Signed-in runs already have it (cached tokens).
51
+ if (!fs.existsSync(dir))
52
+ return;
53
+ fs.writeFileSync(telemetryDisclosureMarkerPath(), `${new Date().toISOString()}\n`);
54
+ }
55
+ catch {
56
+ // Marker write is best effort; a repeat print is better than crashing.
57
+ }
58
+ }
59
+ /**
60
+ * Print the one-line disclosure at most once per machine. Returns whether
61
+ * the notice has been shown (this run or a previous one). `--json` and
62
+ * non-TTY stdout skip the print and do not record the marker.
63
+ */
64
+ export function maybeDiscloseCliTelemetry(stderr = process.stderr, stdout = process.stdout, argv = process.argv) {
65
+ if (hasShownDisclosure())
66
+ return true;
67
+ if (!canPrintDisclosure(stdout, argv))
68
+ return false;
69
+ try {
70
+ stderr.write(`${TELEMETRY_DISCLOSURE_LINE}\n`);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ recordDisclosureShown();
76
+ return true;
77
+ }
6
78
  /**
7
79
  * Emit the authenticated CLI session signal using an already-cached token.
8
80
  * This deliberately never refreshes a session or opens a browser.
@@ -13,12 +85,16 @@ export function emitCliSessionStarted(timeoutMs = TELEMETRY_TIMEOUT_MS) {
13
85
  }
14
86
  async function emitCachedCliSessionStarted(timeoutMs) {
15
87
  try {
88
+ if (isCliTelemetryDisabled())
89
+ return;
16
90
  const cached = loadCachedTokens();
17
91
  if (!cached || isExpiring(cached, 120))
18
92
  return;
19
93
  const token = isMachineIdentity() ? cached.idToken : cached.accessToken;
20
94
  if (!token)
21
95
  return;
96
+ const disclosed = maybeDiscloseCliTelemetry();
97
+ const consentBasis = disclosed ? "disclosed" : "no-consent";
22
98
  const controller = new AbortController();
23
99
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
24
100
  try {
@@ -33,7 +109,7 @@ async function emitCachedCliSessionStarted(timeoutMs) {
33
109
  app: "hq-cli",
34
110
  source: "cli",
35
111
  occurredAt: new Date().toISOString(),
36
- consentBasis: "no-consent",
112
+ consentBasis,
37
113
  schemaVersion: 1,
38
114
  properties: { version: CLI_VERSION },
39
115
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.122.1",
3
+ "version": "5.123.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "assets/scaffold",
14
14
  "assets/bot-workers",
15
15
  "assets/mesh-daemon",
16
- "CHANGELOG.md"
16
+ "CHANGELOG.md",
17
+ "README.md"
17
18
  ],
18
19
  "scripts": {
19
20
  "build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",