@indigoai-us/hq-cli 5.77.13 → 5.78.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 (307) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +51 -2
  33. package/dist/commands/pkg-install.js +43 -0
  34. package/dist/commands/run.js +8 -0
  35. package/dist/commands/secrets.js +125 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2247
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs.test.ts +0 -88
  146. package/src/commands/packs.ts +0 -622
  147. package/src/commands/people.test.ts +0 -807
  148. package/src/commands/people.ts +0 -422
  149. package/src/commands/pkg-install.dispatch.test.ts +0 -71
  150. package/src/commands/pkg-install.ts +0 -215
  151. package/src/commands/pkg-list.test.ts +0 -79
  152. package/src/commands/pkg-list.ts +0 -13
  153. package/src/commands/pkg-remove.ts +0 -71
  154. package/src/commands/pkg-update.ts +0 -189
  155. package/src/commands/publish.test.ts +0 -650
  156. package/src/commands/publish.ts +0 -580
  157. package/src/commands/reindex.test.ts +0 -259
  158. package/src/commands/reindex.ts +0 -274
  159. package/src/commands/rescue.test.ts +0 -39
  160. package/src/commands/rescue.ts +0 -210
  161. package/src/commands/run.env-local.test.ts +0 -88
  162. package/src/commands/run.test.ts +0 -45
  163. package/src/commands/run.ts +0 -184
  164. package/src/commands/safe-extract.test.ts +0 -459
  165. package/src/commands/safe-extract.ts +0 -444
  166. package/src/commands/secrets-scope.test.ts +0 -56
  167. package/src/commands/secrets-scope.ts +0 -32
  168. package/src/commands/secrets.parse-destination.test.ts +0 -38
  169. package/src/commands/secrets.test.ts +0 -2037
  170. package/src/commands/secrets.ts +0 -2240
  171. package/src/commands/signals.ts +0 -351
  172. package/src/commands/skill.test.ts +0 -770
  173. package/src/commands/skill.ts +0 -796
  174. package/src/commands/sources.ts +0 -362
  175. package/src/commands/sync-mode.test.ts +0 -366
  176. package/src/commands/sync-mode.ts +0 -387
  177. package/src/commands/sync-narrow.test.ts +0 -633
  178. package/src/commands/sync-narrow.ts +0 -555
  179. package/src/commands/sync.ts +0 -165
  180. package/src/commands/team-sync.ts +0 -628
  181. package/src/commands/update.ts +0 -71
  182. package/src/commands/whoami.ts +0 -83
  183. package/src/commands/workers.test.ts +0 -158
  184. package/src/commands/workers.ts +0 -301
  185. package/src/index.test.ts +0 -32
  186. package/src/index.ts +0 -20
  187. package/src/lib/db/control-plane.test.ts +0 -59
  188. package/src/lib/db/control-plane.ts +0 -113
  189. package/src/lib/db/local.test.ts +0 -81
  190. package/src/lib/db/local.ts +0 -148
  191. package/src/lib/db/migrate.test.ts +0 -133
  192. package/src/lib/db/migrate.ts +0 -137
  193. package/src/lib/db/paths.test.ts +0 -112
  194. package/src/lib/db/paths.ts +0 -128
  195. package/src/lib/db/remote-engine.test.ts +0 -44
  196. package/src/lib/db/remote-engine.ts +0 -148
  197. package/src/lib/db/remote-sql.test.ts +0 -32
  198. package/src/lib/db/remote-sql.ts +0 -62
  199. package/src/lib/db/sql.test.ts +0 -106
  200. package/src/lib/db/sql.ts +0 -192
  201. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  202. package/src/lib/local-tree-diff.test.ts +0 -301
  203. package/src/lib/local-tree-diff.ts +0 -362
  204. package/src/lib/narrow-hint-banner.test.ts +0 -235
  205. package/src/lib/narrow-hint-banner.ts +0 -212
  206. package/src/lib/plan-limit-nag.test.ts +0 -317
  207. package/src/lib/plan-limit-nag.ts +0 -264
  208. package/src/main.ts +0 -371
  209. package/src/node-preflight.test.ts +0 -60
  210. package/src/node-preflight.ts +0 -67
  211. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  212. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  213. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  214. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  215. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  216. package/src/outpost/session-heartbeat.test.ts +0 -459
  217. package/src/outpost/session-heartbeat.ts +0 -877
  218. package/src/packaging.test.ts +0 -109
  219. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  220. package/src/run/discover-schemas.test.ts +0 -153
  221. package/src/run/discover-schemas.ts +0 -79
  222. package/src/run/hq-plugin.test.ts +0 -371
  223. package/src/run/hq-plugin.ts +0 -277
  224. package/src/run/varlock-shape.test.ts +0 -57
  225. package/src/schemas/hq-package.schema.json +0 -155
  226. package/src/sentry-before-send.test.ts +0 -359
  227. package/src/sentry-before-send.ts +0 -221
  228. package/src/sentry-epipe.test.ts +0 -37
  229. package/src/sentry-release.test.ts +0 -54
  230. package/src/sentry.test.ts +0 -24
  231. package/src/sentry.ts +0 -54
  232. package/src/strategies/link.ts +0 -62
  233. package/src/strategies/merge.ts +0 -142
  234. package/src/types.ts +0 -146
  235. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  236. package/src/utils/auth-error.test.ts +0 -40
  237. package/src/utils/auth-error.ts +0 -42
  238. package/src/utils/billing-gate.test.ts +0 -95
  239. package/src/utils/billing-gate.ts +0 -228
  240. package/src/utils/breadcrumb-buffer.ts +0 -18
  241. package/src/utils/cli-telemetry.test.ts +0 -153
  242. package/src/utils/cli-telemetry.ts +0 -61
  243. package/src/utils/cognito-session.machine.test.ts +0 -109
  244. package/src/utils/cognito-session.refresh.test.ts +0 -84
  245. package/src/utils/cognito-session.test.ts +0 -328
  246. package/src/utils/cognito-session.ts +0 -448
  247. package/src/utils/company-selection-error.test.ts +0 -42
  248. package/src/utils/company-selection-error.ts +0 -45
  249. package/src/utils/contribution-table.ts +0 -83
  250. package/src/utils/environmental-error.test.ts +0 -45
  251. package/src/utils/environmental-error.ts +0 -39
  252. package/src/utils/epipe.test.ts +0 -28
  253. package/src/utils/epipe.ts +0 -29
  254. package/src/utils/expected-cli-error.test.ts +0 -28
  255. package/src/utils/expected-cli-error.ts +0 -39
  256. package/src/utils/feedback-diagnostics.test.ts +0 -183
  257. package/src/utils/feedback-diagnostics.ts +0 -123
  258. package/src/utils/feedback-screenshots.test.ts +0 -134
  259. package/src/utils/feedback-screenshots.ts +0 -124
  260. package/src/utils/feedback-versions.test.ts +0 -98
  261. package/src/utils/feedback-versions.ts +0 -68
  262. package/src/utils/git.ts +0 -74
  263. package/src/utils/integrity.ts +0 -59
  264. package/src/utils/intercepted-process-exit.test.ts +0 -37
  265. package/src/utils/intercepted-process-exit.ts +0 -36
  266. package/src/utils/login-provider.test.ts +0 -42
  267. package/src/utils/login-provider.ts +0 -30
  268. package/src/utils/manifest.test.ts +0 -167
  269. package/src/utils/manifest.ts +0 -131
  270. package/src/utils/pack-contributions.test.ts +0 -603
  271. package/src/utils/pack-contributions.ts +0 -450
  272. package/src/utils/people.ts +0 -215
  273. package/src/utils/registry-client.ts +0 -204
  274. package/src/utils/registry.ts +0 -90
  275. package/src/utils/resolve-vault-credential.test.ts +0 -69
  276. package/src/utils/resolve-vault-credential.ts +0 -60
  277. package/src/utils/sandbox-runner-client.test.ts +0 -279
  278. package/src/utils/sandbox-runner-client.ts +0 -260
  279. package/src/utils/secrets-cache.ts +0 -183
  280. package/src/utils/sentry-identity.ts +0 -45
  281. package/src/utils/settle-with-timeout.test.ts +0 -21
  282. package/src/utils/settle-with-timeout.ts +0 -22
  283. package/src/utils/user-yaml-error.test.ts +0 -24
  284. package/src/utils/user-yaml-error.ts +0 -30
  285. package/src/utils/vault-api.test.ts +0 -536
  286. package/src/utils/vault-api.ts +0 -382
  287. package/src/utils/version-check.test.ts +0 -176
  288. package/src/utils/version-check.ts +0 -155
  289. package/src/utils/version-gate.test.ts +0 -1024
  290. package/src/utils/version-gate.ts +0 -702
  291. package/test/commands/db-tenant-isolation.test.ts +0 -94
  292. package/test/commands/db.test.ts +0 -85
  293. package/test/commands/signals.test.ts +0 -207
  294. package/test/commands/sources.test.ts +0 -231
  295. package/test/e2e/smoke-install-mcp.sh +0 -113
  296. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  297. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  298. package/test/fixtures/signals/action_item/sample.md +0 -16
  299. package/test/fixtures/signals/summary/sample.md +0 -12
  300. package/test/fixtures/sources/meetings/sample.md +0 -25
  301. package/test/helpers/cli-runner.ts +0 -150
  302. package/test/helpers/s3-list-mock.ts +0 -79
  303. package/test/helpers/vault-service-mock.ts +0 -295
  304. package/test/sources-signals/smoke.test.ts +0 -228
  305. package/tsconfig.json +0 -20
  306. package/vitest.config.ts +0 -11
  307. package/vitest.e2e.config.ts +0 -7
@@ -1,1181 +0,0 @@
1
- import { Command } from "commander";
2
- import chalk from "chalk";
3
- import { ensureCognitoToken } from "../utils/cognito-session.js";
4
- import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
5
-
6
- const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
7
- const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
- const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/i;
9
- /** Fleet agent machine email: agt-…@agents.getindigo.ai */
10
- const AGENT_EMAIL_PATTERN =
11
- /^agt-[a-z0-9]+@agents\.getindigo\.ai$/i;
12
- export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
13
- /** Roles allowed when inviting an existing fleet agent into a company. */
14
- export const AGENT_INVITE_ROLES = new Set(["admin", "member"]);
15
-
16
- export type Role = "owner" | "admin" | "member" | "guest";
17
-
18
- export interface PendingInvite {
19
- membershipKey: string;
20
- personUid?: string;
21
- inviteeEmail?: string;
22
- companyUid: string;
23
- role: string;
24
- status: string;
25
- inviteToken?: string;
26
- invitedBy: string;
27
- invitedAt: string;
28
- }
29
-
30
- interface MyMembership {
31
- membershipKey: string;
32
- personUid: string;
33
- companyUid: string;
34
- role: string;
35
- status: string;
36
- }
37
-
38
- /**
39
- * An ACTIVE member of a company, as returned by
40
- * `GET /membership/company/{companyUid}`. The server filters to
41
- * `status: "active"` and enriches each row with resolved person metadata
42
- * (`personEmail` / `personName` / `personSlug`) when available.
43
- */
44
- export interface ActiveMember {
45
- membershipKey: string;
46
- personUid: string;
47
- companyUid: string;
48
- role: string;
49
- status: string;
50
- personEmail?: string;
51
- personName?: string;
52
- personSlug?: string;
53
- }
54
-
55
- export interface InviteOptions {
56
- target: string;
57
- role: string;
58
- paths?: string;
59
- /** Email-keyed invites only — secret-group ids to attach on claim. */
60
- groupIds?: string[];
61
- /**
62
- * Opt the server-side Resend send in/out. When `true`, hq-pro renders +
63
- * sends the invitation email server-side and returns `emailSent` /
64
- * `emailSkipped` / `emailError`. When `false` or unset, the legacy
65
- * no-email path runs and the CLI prints the manual sign-in instructions.
66
- * Requires hq-pro that supports `sendEmail` (older servers ignore the
67
- * field; CLI falls back to the legacy printout in that case).
68
- */
69
- sendEmail?: boolean;
70
- companyUid: string;
71
- callerUid: string;
72
- token: string;
73
- }
74
-
75
- /**
76
- * Outcome of `hq members invite`. Two shapes depending on server schema:
77
- *
78
- * - **schemaVersion ≤ 1** — server returns a random `inviteToken` the
79
- * invitee redeems via the `hq://accept/{token}` magic link. `magicLink`
80
- * is populated so the caller can print or paste it.
81
- * - **schemaVersion 2+** (current production) — membership row is
82
- * email-keyed and authoritative. There is no token; the invitee
83
- * accepts by signing into HQ with the same email. `inviteToken` +
84
- * `magicLink` are both `undefined`; the caller prints sign-in
85
- * instructions instead.
86
- *
87
- * `membership` is always populated when the server returned 2xx.
88
- *
89
- * Email-related fields are populated only when hq-pro performed a
90
- * server-side Resend send (caller passed `sendEmail: true` AND the
91
- * server supports it). Pre-resend servers omit them entirely.
92
- */
93
- export interface InviteResult {
94
- inviteToken?: string;
95
- magicLink?: string;
96
- membership: {
97
- membershipKey?: string;
98
- role: string;
99
- status: string;
100
- inviteToken?: string;
101
- inviteeEmail?: string;
102
- };
103
- /** Resend send actually fired and accepted by Resend. */
104
- emailSent?: boolean;
105
- /** Resend skipped because the server has no RESEND_API_KEY configured. */
106
- emailSkipped?: boolean;
107
- /** Resend was attempted but failed — human-readable reason. */
108
- emailError?: string;
109
- /**
110
- * Present when hq-pro treated the target as a fleet agent (guest invite).
111
- * Always `false` on the agent path — host keeps billing.
112
- */
113
- activationBilled?: boolean;
114
- /** Host company uid when the server returned an agent guest-invite result. */
115
- hostCompanyUid?: string;
116
- }
117
-
118
- export interface ResendInviteOptions {
119
- inviteeEmail: string;
120
- companyUid: string;
121
- callerUid: string;
122
- token: string;
123
- }
124
-
125
- /**
126
- * Outcome of `hq members invite --resend`. No new membership row was
127
- * created — the server's `resend: true` short-circuit just re-fires the
128
- * Resend email against the existing pending row.
129
- */
130
- export interface ResendInviteResult {
131
- resent: true;
132
- emailSent: boolean;
133
- emailSkipped: boolean;
134
- emailError?: string;
135
- }
136
-
137
- export interface DetectedTarget {
138
- type: "email" | "person" | "agent";
139
- value: string;
140
- /** True when the target is a fleet agent (uid or machine email). */
141
- isAgent?: boolean;
142
- }
143
-
144
- export function detectTarget(target: string): DetectedTarget | null {
145
- const trimmed = target.trim();
146
- if (AGENT_UID_PATTERN.test(trimmed)) {
147
- return { type: "agent", value: trimmed, isAgent: true };
148
- }
149
- if (AGENT_EMAIL_PATTERN.test(trimmed)) {
150
- return {
151
- type: "email",
152
- value: trimmed.toLowerCase(),
153
- isAgent: true,
154
- };
155
- }
156
- if (EMAIL_PATTERN.test(trimmed)) {
157
- return { type: "email", value: trimmed.toLowerCase() };
158
- }
159
- if (PERSON_UID_PATTERN.test(trimmed)) {
160
- return { type: "person", value: trimmed };
161
- }
162
- return null;
163
- }
164
-
165
- export function shortDate(iso: string): string {
166
- return iso.slice(0, 10);
167
- }
168
-
169
- /**
170
- * Resolve the caller's personUid by reading their own membership list.
171
- * The server infers the JWT identity, so this returns the canonical
172
- * personUid attached to the caller's active memberships.
173
- */
174
- export async function getCallerPersonUid(token: string): Promise<string> {
175
- const res = await vaultApiFetch({ token, path: "/membership/me" });
176
- if (!res.ok) {
177
- throw new Error(
178
- "Failed to resolve caller identity — run `hq login` and try again",
179
- );
180
- }
181
- const data = (await res.json()) as { memberships: MyMembership[] };
182
- const personUid = data.memberships.find((m) => m.personUid)?.personUid;
183
- if (!personUid) {
184
- throw new Error(
185
- "Your account has no person entity yet. Run `hq onboard create-company` or accept an invite first.",
186
- );
187
- }
188
- return personUid;
189
- }
190
-
191
- /** Send a `/membership/invite` request and return the magic link. */
192
- export async function inviteMember(
193
- options: InviteOptions,
194
- ): Promise<InviteResult> {
195
- if (!VALID_ROLES.has(options.role)) {
196
- throw new Error(
197
- `Invalid role '${options.role}': must be one of owner, admin, member, guest`,
198
- );
199
- }
200
- if (options.paths && options.role !== "guest") {
201
- throw new Error(
202
- "--paths is only valid with --role guest (allowedPrefixes are only meaningful for the guest role)",
203
- );
204
- }
205
-
206
- const detected = detectTarget(options.target);
207
- if (!detected) {
208
- throw new Error(
209
- `Invalid target '${options.target}': must be an email, personUid (prs_…), agent uid (agt_…), or agent email (agt-…@agents.getindigo.ai)`,
210
- );
211
- }
212
-
213
- // Fleet agents: same invite command as humans, but only member|admin.
214
- // Host company keeps billing — server never double-bills on guest invite.
215
- if (detected.isAgent) {
216
- if (!AGENT_INVITE_ROLES.has(options.role)) {
217
- throw new Error(
218
- `Agents can only be invited as member or admin (got '${options.role}'). Host company keeps billing.`,
219
- );
220
- }
221
- if (options.paths) {
222
- throw new Error(
223
- "--paths / guest role is not valid for agents — invite as member or admin",
224
- );
225
- }
226
- if (options.groupIds && options.groupIds.length > 0) {
227
- throw new Error(
228
- "--groups is not supported on agent invites — share secrets after membership with `hq secrets share`",
229
- );
230
- }
231
- }
232
-
233
- if (
234
- options.groupIds &&
235
- options.groupIds.length > 0 &&
236
- (detected.type === "person" || detected.type === "agent")
237
- ) {
238
- throw new Error(
239
- "--groups is only valid on email-keyed invites (server rejects personUid + groupIds with 400)",
240
- );
241
- }
242
-
243
- const allowedPrefixes = options.paths
244
- ? options.paths.split(",").map((p) => p.trim()).filter(Boolean)
245
- : undefined;
246
-
247
- const body: Record<string, unknown> = {
248
- companyUid: options.companyUid,
249
- role: options.role,
250
- invitedBy: options.callerUid,
251
- };
252
- if (detected.type === "email") body.inviteeEmail = detected.value;
253
- else body.personUid = detected.value;
254
- if (allowedPrefixes) body.allowedPrefixes = allowedPrefixes;
255
- if (options.groupIds && options.groupIds.length > 0) {
256
- body.groupIds = options.groupIds;
257
- }
258
- if (options.sendEmail === true) body.sendEmail = true;
259
-
260
- const res = await vaultApiFetch({
261
- token: options.token,
262
- path: "/membership/invite",
263
- method: "POST",
264
- body,
265
- });
266
-
267
- if (!res.ok) {
268
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
269
- throw new InviteHttpError(
270
- res.status,
271
- err.message ?? err.error ?? res.statusText,
272
- err.code,
273
- );
274
- }
275
-
276
- const data = (await res.json()) as {
277
- membership?: {
278
- membershipKey?: string;
279
- role: string;
280
- status: string;
281
- inviteToken?: string;
282
- inviteeEmail?: string;
283
- schemaVersion?: number;
284
- };
285
- inviteToken?: string;
286
- emailSent?: boolean;
287
- emailSkipped?: boolean;
288
- emailError?: string;
289
- activationBilled?: boolean;
290
- hostCompanyUid?: string;
291
- };
292
- if (!data.membership) {
293
- const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
294
- throw new Error(
295
- `Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
296
- "This is a server-side regression — file an issue.",
297
- );
298
- }
299
- // Two server schemas in the wild:
300
- // - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
301
- // the invitee redeems via `hq://accept/{token}`.
302
- // - Current (schemaVersion 2+): membership row is email-keyed and
303
- // authoritative — there is no token. The invitee accepts by signing
304
- // into HQ with the invited email. The CLI must NOT throw here
305
- // (previously did: "response did not include an invite token") — the
306
- // invite IS successfully created on the server; the caller just gets
307
- // undefined for inviteToken/magicLink and prints sign-in instructions.
308
- const inviteToken = data.inviteToken ?? data.membership.inviteToken;
309
- return {
310
- ...(inviteToken
311
- ? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
312
- : {}),
313
- membership: data.membership,
314
- // Pre-resend hq-pro versions omit these fields entirely — leave the
315
- // result fields undefined so the caller's "is server-side email
316
- // supported?" check works via `typeof result.emailSent === 'boolean'`.
317
- ...(typeof data.emailSent === "boolean" ? { emailSent: data.emailSent } : {}),
318
- ...(typeof data.emailSkipped === "boolean"
319
- ? { emailSkipped: data.emailSkipped }
320
- : {}),
321
- ...(data.emailError ? { emailError: data.emailError } : {}),
322
- ...(typeof data.activationBilled === "boolean"
323
- ? { activationBilled: data.activationBilled }
324
- : {}),
325
- ...(typeof data.hostCompanyUid === "string"
326
- ? { hostCompanyUid: data.hostCompanyUid }
327
- : {}),
328
- };
329
- }
330
-
331
- /**
332
- * Re-fire the invite email against an existing pending row without
333
- * creating a new membership row. Maps to hq-pro `/membership/invite`
334
- * with `{ resend: true }` (see hq-pro PR #140). Server gates on the
335
- * caller's admin/owner role.
336
- *
337
- * Returns the email-send status. 404 here means "no pending row exists
338
- * for this email+company pair — re-invite without --resend first."
339
- */
340
- export async function resendInvite(
341
- options: ResendInviteOptions,
342
- ): Promise<ResendInviteResult> {
343
- const detected = detectTarget(options.inviteeEmail);
344
- if (!detected || detected.type !== "email") {
345
- throw new Error(
346
- "--resend requires an email target (the resend path is email-keyed-row only)",
347
- );
348
- }
349
-
350
- const res = await vaultApiFetch({
351
- token: options.token,
352
- path: "/membership/invite",
353
- method: "POST",
354
- body: {
355
- companyUid: options.companyUid,
356
- // Server's invite handler still requires `role` even on the resend
357
- // short-circuit. Pass the safe default — server ignores it on this
358
- // path (the pending row already has its real role).
359
- role: "member",
360
- inviteeEmail: detected.value,
361
- invitedBy: options.callerUid,
362
- resend: true,
363
- },
364
- });
365
-
366
- if (!res.ok) {
367
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
368
- throw new InviteHttpError(
369
- res.status,
370
- err.message ?? err.error ?? res.statusText,
371
- err.code,
372
- );
373
- }
374
-
375
- const data = (await res.json()) as {
376
- resent?: boolean;
377
- emailSent?: boolean;
378
- emailSkipped?: boolean;
379
- emailError?: string;
380
- };
381
- if (data.resent !== true) {
382
- throw new Error(
383
- "Resend endpoint returned 2xx without `resent: true` — the connected hq-pro likely doesn't support `resend: true` yet. Upgrade hq-pro or re-invite without --resend.",
384
- );
385
- }
386
- return {
387
- resent: true,
388
- emailSent: data.emailSent === true,
389
- emailSkipped: data.emailSkipped === true,
390
- ...(data.emailError ? { emailError: data.emailError } : {}),
391
- };
392
- }
393
-
394
- export class InviteHttpError extends Error {
395
- constructor(
396
- public status: number,
397
- message: string,
398
- public code?: string,
399
- ) {
400
- super(message);
401
- this.name = "InviteHttpError";
402
- }
403
- }
404
-
405
- export function formatInviteHttpError(
406
- status: number,
407
- fallback: string,
408
- code?: string,
409
- ): string {
410
- if (status === 401) return "Not authenticated — please run `hq login`";
411
- if (status === 403) {
412
- if (code === "ADMIN_ROLE_TARGET_RESTRICTED") {
413
- return "Admin can only invite at role=member; ask an owner to invite at this role";
414
- }
415
- return "Not authorized — only admins and owners can invite members";
416
- }
417
- if (status === 409) {
418
- return "This person already has a membership or pending invite for this company";
419
- }
420
- if (status >= 500) return `Server error: ${fallback}`;
421
- return fallback;
422
- }
423
-
424
- export async function listPendingInvites(
425
- token: string,
426
- companyUid: string,
427
- ): Promise<PendingInvite[]> {
428
- const res = await vaultApiFetch({
429
- token,
430
- path: `/membership/company/${encodeURIComponent(companyUid)}/pending`,
431
- });
432
- if (!res.ok) {
433
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
434
- throw new InviteHttpError(
435
- res.status,
436
- err.message ?? err.error ?? res.statusText,
437
- err.code,
438
- );
439
- }
440
- // Server schema: `{ pending: [...] }`. Earlier dev branches used
441
- // `{ invites: [...] }` which the CLI still accepts as a fallback for
442
- // operators running staging stages that haven't caught up yet.
443
- const data = (await res.json()) as {
444
- pending?: PendingInvite[] | null;
445
- invites?: PendingInvite[] | null;
446
- };
447
- return data?.pending ?? data?.invites ?? [];
448
- }
449
-
450
- export async function listActiveMembers(
451
- token: string,
452
- companyUid: string,
453
- ): Promise<ActiveMember[]> {
454
- const res = await vaultApiFetch({
455
- token,
456
- path: `/membership/company/${encodeURIComponent(companyUid)}`,
457
- });
458
- if (!res.ok) {
459
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
460
- throw new InviteHttpError(
461
- res.status,
462
- err.message ?? err.error ?? res.statusText,
463
- err.code,
464
- );
465
- }
466
- // Server schema: `{ members: [...] }` — active members only, enriched with
467
- // resolved person metadata (personEmail / personName / personSlug).
468
- const data = (await res.json()) as {
469
- members?: ActiveMember[] | null;
470
- };
471
- return data?.members ?? [];
472
- }
473
-
474
- /**
475
- * Resolve a role-change target to an active membership key.
476
- *
477
- * Pending invites are email-keyed, but claimed active memberships are keyed by
478
- * personUid. Consequently, role changes must look up email targets in the
479
- * active member list instead of synthesizing an `email:...#company` key.
480
- */
481
- export async function resolveRoleChangeTarget(
482
- token: string,
483
- companyUid: string,
484
- target: string,
485
- ): Promise<string> {
486
- if (target.includes("#")) return target;
487
-
488
- const detected = detectTarget(target);
489
- if (detected?.type === "person" || detected?.type === "agent") {
490
- return `${detected.value}#${companyUid}`;
491
- }
492
- if (detected?.type === "email") {
493
- // Active fleet-agent guest memberships are keyed agt_…#cmp_…, not by
494
- // personEmail enrichment. Map machine emails the same way revoke does
495
- // before falling through to the human roster lookup.
496
- if (detected.isAgent) {
497
- const local = detected.value.split("@")[0] ?? "";
498
- const m = local.match(/^agt-(.+)$/i);
499
- if (m) return `agt_${m[1].toUpperCase()}#${companyUid}`;
500
- }
501
-
502
- const members = await listActiveMembers(token, companyUid);
503
- const member = members.find(
504
- (candidate) =>
505
- candidate.personEmail?.trim().toLowerCase() === detected.value,
506
- );
507
- if (member) return member.membershipKey;
508
-
509
- throw new Error(
510
- `No active member has email '${detected.value}' in this company. ` +
511
- "Run `hq members list` to find the member, or use their prs_ personUid.",
512
- );
513
- }
514
-
515
- throw new Error(
516
- `Invalid target '${target}': use an email, prs_ personUid, agt_ agentUid, or full membership key`,
517
- );
518
- }
519
-
520
- /**
521
- * Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
522
- * the server requires. Accepts three input forms:
523
- *
524
- * 1. Full membership key — already has `#<companyUid>`; passed through.
525
- * Examples: `email:alice@example.com#cmp_abc`, `prs_abc#cmp_abc`.
526
- * 2. Bare email — wrap as `email:<email>#<companyUid>`.
527
- * 3. Bare personUid (`prs_*`) — wrap as `<personUid>#<companyUid>`.
528
- * 4. Anything else (e.g. legacy schemaVersion-1 inviteToken) — pass through
529
- * so the server can decide. Server may 404 if the token doesn't resolve.
530
- *
531
- * Pure function — no I/O — so it's trivially unit-testable. The previous
532
- * shape sent the user's raw arg straight through, which meant `hq members
533
- * revoke alice@example.com` always 404'd ("Invite not found") even when
534
- * that exact email was just shown by `hq members list`.
535
- */
536
- export function resolveRevokeTargetToMembershipKey(
537
- arg: string,
538
- companyUid: string,
539
- ): string {
540
- if (arg.includes("#")) return arg;
541
- const detected = detectTarget(arg);
542
- if (detected?.type === "email") {
543
- // Active agent guest memberships are personUid-keyed (agt_…#cmp_…), not
544
- // email-keyed. Map machine emails to that key so revoke works after invite.
545
- if (detected.isAgent) {
546
- const local = detected.value.split("@")[0] ?? "";
547
- const m = local.match(/^agt-(.+)$/i);
548
- if (m) return `agt_${m[1].toUpperCase()}#${companyUid}`;
549
- }
550
- return `email:${detected.value}#${companyUid}`;
551
- }
552
- if (detected?.type === "person" || detected?.type === "agent") {
553
- return `${detected.value}#${companyUid}`;
554
- }
555
- return arg;
556
- }
557
-
558
- function printEmailStatus(opts: {
559
- recipient: string;
560
- sent: boolean;
561
- skipped: boolean;
562
- error?: string;
563
- }): void {
564
- if (opts.sent) {
565
- console.log(
566
- chalk.green(`✓ Invitation email sent to ${opts.recipient.toLowerCase()}`),
567
- );
568
- return;
569
- }
570
- if (opts.skipped) {
571
- console.log(
572
- chalk.yellow(
573
- `⚠ Email send skipped — hq-pro has no RESEND_API_KEY configured for this stage.`,
574
- ),
575
- );
576
- return;
577
- }
578
- if (opts.error) {
579
- console.log(chalk.yellow(`⚠ Email send failed: ${opts.error}`));
580
- return;
581
- }
582
- console.log(
583
- chalk.yellow(
584
- `⚠ Email status unknown — hq-pro returned no email fields (likely a pre-resend deploy).`,
585
- ),
586
- );
587
- }
588
-
589
- export async function revokeInvite(
590
- token: string,
591
- tokenOrKey: string,
592
- companyUid: string,
593
- ): Promise<void> {
594
- const res = await vaultApiFetch({
595
- token,
596
- path: "/membership/revoke",
597
- method: "POST",
598
- body: { membershipKey: tokenOrKey, companyUid },
599
- });
600
- if (!res.ok) {
601
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
602
- throw new InviteHttpError(
603
- res.status,
604
- err.message ?? err.error ?? res.statusText,
605
- err.code,
606
- );
607
- }
608
- }
609
-
610
- /**
611
- * Change a member's role via `POST /membership/role`, accepting the FULL role
612
- * set (owner|admin|member|guest). This is a GENERAL role change — it can promote
613
- * OR demote. Authorization (owner-or-admin `changeRoles`, owner-only
614
- * promote-to-owner / change-an-owner) is enforced SERVER-side; this function
615
- * only validates the role string locally and surfaces the server's error.
616
- * Role string is validated BEFORE any network call so callers/tests can rely on
617
- * a synchronous-shaped rejection for a bad role.
618
- */
619
- export async function changeMemberRole(
620
- token: string,
621
- companyUid: string,
622
- membershipKey: string,
623
- newRole: Role,
624
- ): Promise<void> {
625
- if (!VALID_ROLES.has(newRole)) {
626
- throw new Error(
627
- `Invalid role '${newRole}': must be one of owner, admin, member, guest`,
628
- );
629
- }
630
-
631
- const res = await vaultApiFetch({
632
- token,
633
- path: "/membership/role",
634
- method: "POST",
635
- body: { companyUid, membershipKey, newRole },
636
- });
637
- if (!res.ok) {
638
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
639
- throw new InviteHttpError(
640
- res.status,
641
- err.message ?? err.error ?? res.statusText,
642
- err.code,
643
- );
644
- }
645
- }
646
-
647
- export function registerMembersCommand(program: Command): void {
648
- const members = program
649
- .command("members")
650
- .description("Manage company memberships and invites")
651
- .option("--company <slug>", "Company slug (resolves to companyUid)");
652
-
653
- // Canonical role-change command. `promote` is the discoverable verb users
654
- // reach for; `set-role` is kept as an alias for the older name. The route is a
655
- // GENERAL role change, so the help text and success message are honest that it
656
- // can demote as well as promote. Authorization is enforced server-side.
657
- members
658
- .command("promote <target> <newRole>")
659
- .alias("set-role")
660
- .description(
661
- "Change a member's role (owner|admin|member|guest) — promotes OR demotes. " +
662
- "<target> may be an email, a prs_ personUid, or a full membership key. " +
663
- "Owner-or-admin only; setting a target to owner (or changing an owner) is owner-only. Server-enforced.",
664
- )
665
- .action(async (target: string, newRole: string) => {
666
- try {
667
- const role = newRole.trim().toLowerCase();
668
- if (!VALID_ROLES.has(role)) {
669
- console.error(
670
- chalk.red(
671
- `Invalid role '${newRole}': must be one of owner, admin, member, guest`,
672
- ),
673
- );
674
- process.exit(1);
675
- }
676
-
677
- const token = await ensureCognitoToken();
678
- const companySlug = members.opts().company as string | undefined;
679
- const companyUid = await getCompanyUid(token, companySlug);
680
- const membershipKey = await resolveRoleChangeTarget(
681
- token,
682
- companyUid,
683
- target,
684
- );
685
-
686
- await changeMemberRole(token, companyUid, membershipKey, role as Role);
687
- console.log(
688
- chalk.green(`Updated role for '${target}' to ${role}`),
689
- );
690
- if (role === "owner" || role === "admin") {
691
- // Clarify the common confusion: owners/admins already get full
692
- // access to ALL company context automatically via the role bypass
693
- // (hq-pro resolveEffectivePermission) — there is no separate context
694
- // grant to give them. What they SEE locally is governed by their sync
695
- // mode, not by access.
696
- console.log(
697
- chalk.dim(
698
- `${role === "owner" ? "Owners" : "Admins"} automatically have full read and write access to all of this company's context — no separate context grant is needed.\n` +
699
- `What they actually see synced to their machine is governed by their sync mode (\`hq sync mode\`), not by access.`,
700
- ),
701
- );
702
- }
703
- } catch (err) {
704
- if (err instanceof InviteHttpError) {
705
- console.error(chalk.red(err.message));
706
- process.exit(1);
707
- }
708
- console.error(
709
- chalk.red("Error:"),
710
- err instanceof Error ? err.message : String(err),
711
- );
712
- process.exit(1);
713
- }
714
- });
715
-
716
- members
717
- .command("invite <target>")
718
- .description(
719
- "Invite a person or fleet agent to the company. People: email or prs_…. Agents: agt_… or agt-…@agents.getindigo.ai (member|admin; host keeps billing, no double charge).",
720
- )
721
- .option(
722
- "--role <role>",
723
- "Role: person → owner|admin|member|guest; agent → member|admin only",
724
- "member",
725
- )
726
- .option(
727
- "--paths <prefixes>",
728
- "Comma-separated allowed prefixes (only valid with --role guest)",
729
- )
730
- .option(
731
- "--groups <ids>",
732
- "Comma-separated secret-group ids the invitee will be added to on claim (email-keyed invites only)",
733
- )
734
- .option(
735
- "--send-email",
736
- "Have hq-pro send an invitation email via Resend (default true for email-keyed invites). Pre-resend hq-pro versions ignore this and the legacy 'no email sent' instructions print instead.",
737
- true,
738
- )
739
- .option(
740
- "--no-send-email",
741
- "Skip the server-side Resend send — only create the pending DDB row. Useful when you're scripting bulk invites and will send the announcement out-of-band.",
742
- )
743
- .option(
744
- "--resend",
745
- "Re-fire the invitation email against an existing pending row without creating a new row. Maps to hq-pro `resend: true` short-circuit. Email-keyed invites only.",
746
- )
747
- .action(
748
- async (
749
- target: string,
750
- opts: {
751
- role: string;
752
- paths?: string;
753
- groups?: string;
754
- sendEmail: boolean;
755
- resend?: boolean;
756
- },
757
- ) => {
758
- let token: string | undefined;
759
- let companyUid: string | undefined;
760
- let companySlug: string | undefined;
761
- try {
762
- token = await ensureCognitoToken();
763
- companySlug = members.opts().company as string | undefined;
764
- companyUid = await getCompanyUid(token, companySlug);
765
- const callerUid = await getCallerPersonUid(token);
766
-
767
- // --resend short-circuits to the server's re-fire path. No new row.
768
- if (opts.resend === true) {
769
- const resendResult = await resendInvite({
770
- inviteeEmail: target,
771
- companyUid,
772
- callerUid,
773
- token,
774
- });
775
- console.log(
776
- chalk.green(
777
- `Re-fired invite email for ${target.toLowerCase()} (existing pending row left untouched)`,
778
- ),
779
- );
780
- console.log();
781
- printEmailStatus({
782
- recipient: target,
783
- sent: resendResult.emailSent,
784
- skipped: resendResult.emailSkipped,
785
- error: resendResult.emailError,
786
- });
787
- return;
788
- }
789
-
790
- const groupIds = opts.groups
791
- ? opts.groups.split(",").map((g) => g.trim()).filter(Boolean)
792
- : undefined;
793
-
794
- const result = await inviteMember({
795
- target,
796
- role: opts.role,
797
- paths: opts.paths,
798
- groupIds,
799
- // Agents join actively — no Resend email.
800
- sendEmail: detectTarget(target)?.isAgent ? false : opts.sendEmail,
801
- companyUid,
802
- callerUid,
803
- token,
804
- });
805
-
806
- const agentTarget = detectTarget(target)?.isAgent === true;
807
- console.log(
808
- chalk.green(
809
- `Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`,
810
- ),
811
- );
812
- // Agent path is confirmed only when the server returned the guest-invite
813
- // envelope (activationBilled present) AND status is active. A pending
814
- // row means this hq-pro stage has not rolled the agent invite path yet —
815
- // do NOT claim active join / no-double-bill.
816
- if (
817
- agentTarget &&
818
- result.membership.status === "active" &&
819
- typeof result.activationBilled === "boolean"
820
- ) {
821
- console.log(
822
- chalk.dim(
823
- `Fleet agent: active membership granted immediately` +
824
- (result.hostCompanyUid
825
- ? ` (host ${result.hostCompanyUid} keeps billing)`
826
- : " (host keeps billing, no double charge)") +
827
- `. Next: share secrets / vault paths as needed (\`hq secrets share\`, \`/new-agent\`).`,
828
- ),
829
- );
830
- console.log();
831
- return;
832
- }
833
- if (agentTarget && result.membership.status !== "active") {
834
- console.log(
835
- chalk.yellow(
836
- "⚠ Agent invite landed as a pending membership — this hq-pro stage may not yet have multi-company agent invite. Wait for prod deploy of the agent membership path, then re-invite (or revoke this pending row).",
837
- ),
838
- );
839
- console.log();
840
- return;
841
- }
842
- console.log();
843
- if (result.magicLink) {
844
- // Legacy server schema — magic-link redemption.
845
- console.log(chalk.bold("Magic link:"));
846
- console.log(` ${result.magicLink}`);
847
- console.log();
848
- console.log(
849
- chalk.dim(
850
- "Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
851
- ),
852
- );
853
- return;
854
- }
855
-
856
- // schemaVersion 2+ — email-keyed authoritative membership row.
857
- // Three output branches depending on whether the server-side
858
- // Resend send fired:
859
- // (a) emailSent === true → "email sent" success line
860
- // (b) emailSkipped === true → server has no RESEND_API_KEY;
861
- // fall back to the manual-notify instructions
862
- // (c) emailSent/emailSkipped both undefined → pre-resend
863
- // hq-pro, doesn't understand `sendEmail` at all; fall
864
- // back to the manual-notify instructions
865
- const inviteeEmail =
866
- result.membership.inviteeEmail ??
867
- (typeof target === "string" && target.includes("@")
868
- ? target
869
- : undefined);
870
- const emailDelivered = result.emailSent === true;
871
- if (emailDelivered) {
872
- printEmailStatus({
873
- recipient: inviteeEmail ?? target,
874
- sent: true,
875
- skipped: false,
876
- error: undefined,
877
- });
878
- if (result.membership.membershipKey) {
879
- console.log();
880
- console.log(
881
- chalk.dim(`Membership key: ${result.membership.membershipKey}`),
882
- );
883
- }
884
- return;
885
- }
886
- // Email-send failed or wasn't attempted — surface the manual path.
887
- if (result.emailError) {
888
- console.log(
889
- chalk.yellow(`⚠ Email send failed: ${result.emailError}`),
890
- );
891
- console.log();
892
- }
893
- console.log(
894
- chalk.yellow(
895
- "⚠ No email was sent. The pending membership row exists; the invitee needs to sign in manually to claim it.",
896
- ),
897
- );
898
- console.log();
899
- console.log(chalk.bold("To complete the invite, do ONE of:"));
900
- console.log(
901
- ` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`,
902
- );
903
- console.log(
904
- ` at https://hq.computer with that email address.`,
905
- );
906
- console.log(
907
- ` 2. Or use the hq-console UI at https://hq.computer to issue the`,
908
- );
909
- console.log(
910
- ` invite instead — the UI path triggers an automated email via Resend.`,
911
- );
912
- if (result.emailSkipped !== true && result.emailSent === undefined) {
913
- console.log();
914
- console.log(
915
- chalk.dim(
916
- "Tip: this hq-pro deploy doesn't support server-side email yet (`sendEmail` ignored). Ask an operator to ship hq-pro PR #140 + provision the ResendApiKey SST secret.",
917
- ),
918
- );
919
- } else if (result.emailSkipped === true) {
920
- console.log();
921
- console.log(
922
- chalk.dim(
923
- "Tip: hq-pro reported `emailSkipped: true` — its `RESEND_API_KEY` SST secret isn't set for this stage.",
924
- ),
925
- );
926
- }
927
- console.log();
928
- console.log(
929
- chalk.dim(
930
- "The pending membership row claims itself on the invitee's first sign-in.",
931
- ),
932
- );
933
- if (result.membership.membershipKey) {
934
- console.log();
935
- console.log(
936
- chalk.dim(`Membership key: ${result.membership.membershipKey}`),
937
- );
938
- }
939
- } catch (err) {
940
- // A 409 may refer to either an active membership or an existing
941
- // pending invite. Probe both views so email targets get actionable
942
- // guidance while preserving the terminal exit-0 behavior for bulk
943
- // invite scripts.
944
- if (
945
- err instanceof InviteHttpError &&
946
- err.code === "MEMBERSHIP_ALREADY_EXISTS"
947
- ) {
948
- const detected = detectTarget(target);
949
- const normalizedEmail =
950
- detected?.type === "email" ? detected.value : undefined;
951
-
952
- if (normalizedEmail && token && companyUid) {
953
- // Preserve --company in copy-paste hints so multi-company
954
- // callers don't hit "Multiple active companies" on --resend.
955
- const companyFlag = companySlug
956
- ? ` --company ${companySlug}`
957
- : "";
958
- const resendHint =
959
- `hq members invite ${normalizedEmail}${companyFlag} --resend`;
960
-
961
- try {
962
- const pending = await listPendingInvites(token, companyUid);
963
- if (
964
- pending.some(
965
- (invite) =>
966
- invite.inviteeEmail?.trim().toLowerCase() ===
967
- normalizedEmail,
968
- )
969
- ) {
970
- console.log(
971
- chalk.yellow(
972
- `${normalizedEmail} already has a pending invite. ` +
973
- `Use \`${resendHint}\` to re-send it.`,
974
- ),
975
- );
976
- return;
977
- }
978
- } catch {
979
- // Pending-list visibility can vary by server version or role.
980
- // Fall through to the active-member probe and generic copy.
981
- }
982
-
983
- try {
984
- const activeMembers = await listActiveMembers(token, companyUid);
985
- if (
986
- activeMembers.some(
987
- (member) =>
988
- member.personEmail?.trim().toLowerCase() ===
989
- normalizedEmail,
990
- )
991
- ) {
992
- console.log(
993
- chalk.yellow(
994
- `${normalizedEmail} is already a member of this company — nothing to do.`,
995
- ),
996
- );
997
- return;
998
- }
999
- } catch {
1000
- // Preserve the original conflict as the source of truth and
1001
- // use copy that remains accurate when the probes are hidden.
1002
- }
1003
-
1004
- console.log(
1005
- chalk.yellow(
1006
- `${normalizedEmail} already has a membership or pending invite for this company. ` +
1007
- `If the invite is pending, use \`${resendHint}\`.`,
1008
- ),
1009
- );
1010
- return;
1011
- }
1012
-
1013
- console.log(
1014
- chalk.yellow(
1015
- `${target.toLowerCase()} is already a member of this company — nothing to do.`,
1016
- ),
1017
- );
1018
- return;
1019
- }
1020
- if (err instanceof InviteHttpError) {
1021
- console.error(
1022
- chalk.red(
1023
- formatInviteHttpError(err.status, err.message, err.code),
1024
- ),
1025
- );
1026
- process.exit(1);
1027
- }
1028
- console.error(
1029
- chalk.red("Error:"),
1030
- err instanceof Error ? err.message : String(err),
1031
- );
1032
- process.exit(1);
1033
- }
1034
- },
1035
- );
1036
-
1037
- members
1038
- .command("list")
1039
- .description(
1040
- "List the company's active members (use --pending for pending invites)",
1041
- )
1042
- .option("--pending", "List pending invites instead of active members")
1043
- .action(async (opts: { pending?: boolean }) => {
1044
- try {
1045
- const token = await ensureCognitoToken();
1046
- const companySlug = members.opts().company as string | undefined;
1047
- const companyUid = await getCompanyUid(token, companySlug);
1048
-
1049
- if (opts.pending) {
1050
- // --pending: preserve the original pending-invites view verbatim.
1051
- const invites = await listPendingInvites(token, companyUid);
1052
-
1053
- if (invites.length === 0) {
1054
- console.log(chalk.gray("No pending invites for this company."));
1055
- return;
1056
- }
1057
-
1058
- const targetW = Math.max(
1059
- 6,
1060
- ...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length),
1061
- );
1062
- const roleW = Math.max(4, ...invites.map((i) => i.role.length));
1063
- const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
1064
- const keyW = Math.max(
1065
- 14,
1066
- ...invites.map((i) => i.membershipKey.length),
1067
- );
1068
- console.log(
1069
- chalk.bold(
1070
- [
1071
- "TARGET".padEnd(targetW),
1072
- "ROLE".padEnd(roleW),
1073
- "INVITED_BY".padEnd(byW),
1074
- "INVITED_AT",
1075
- "MEMBERSHIP_KEY".padEnd(keyW),
1076
- ].join(" "),
1077
- ),
1078
- );
1079
- for (const inv of invites) {
1080
- const target = inv.inviteeEmail ?? inv.personUid ?? "";
1081
- console.log(
1082
- [
1083
- target.padEnd(targetW),
1084
- inv.role.padEnd(roleW),
1085
- inv.invitedBy.padEnd(byW),
1086
- shortDate(inv.invitedAt),
1087
- inv.membershipKey.padEnd(keyW),
1088
- ].join(" "),
1089
- );
1090
- }
1091
- return;
1092
- }
1093
-
1094
- // Default: list ACTIVE members so their emails drop straight into
1095
- // `hq secrets share <path> --with <email>`.
1096
- const activeMembers = await listActiveMembers(token, companyUid);
1097
-
1098
- if (activeMembers.length === 0) {
1099
- console.log(chalk.gray("No active members found for this company."));
1100
- return;
1101
- }
1102
-
1103
- const emailW = Math.max(
1104
- 5,
1105
- ...activeMembers.map((m) => (m.personEmail ?? m.personUid).length),
1106
- );
1107
- const roleW = Math.max(4, ...activeMembers.map((m) => m.role.length));
1108
- console.log(
1109
- chalk.bold(
1110
- ["EMAIL".padEnd(emailW), "ROLE".padEnd(roleW), "NAME"].join(" "),
1111
- ),
1112
- );
1113
- for (const m of activeMembers) {
1114
- const email = m.personEmail ?? m.personUid;
1115
- const name = m.personName ?? m.personSlug ?? "";
1116
- console.log(
1117
- [email.padEnd(emailW), m.role.padEnd(roleW), name].join(" "),
1118
- );
1119
- }
1120
- console.log(
1121
- chalk.gray(
1122
- "Share a secret with a member: hq secrets share <path> --with <email>",
1123
- ),
1124
- );
1125
- } catch (err) {
1126
- if (err instanceof InviteHttpError) {
1127
- const msg =
1128
- err.status === 403
1129
- ? opts.pending
1130
- ? "Not authorized — only admins and owners can list invites"
1131
- : "Not authorized — only company members can list members"
1132
- : formatInviteHttpError(err.status, err.message);
1133
- console.error(chalk.red(msg));
1134
- process.exit(1);
1135
- }
1136
- console.error(
1137
- chalk.red("Error:"),
1138
- err instanceof Error ? err.message : String(err),
1139
- );
1140
- process.exit(1);
1141
- }
1142
- });
1143
-
1144
- members
1145
- .command("revoke <target>")
1146
- .description(
1147
- "Revoke a pending invite. Accepts an email, personUid, full membershipKey, or legacy inviteToken.",
1148
- )
1149
- .action(async (target: string) => {
1150
- try {
1151
- const token = await ensureCognitoToken();
1152
- const companySlug = members.opts().company as string | undefined;
1153
- const companyUid = await getCompanyUid(token, companySlug);
1154
-
1155
- const membershipKey = resolveRevokeTargetToMembershipKey(
1156
- target,
1157
- companyUid,
1158
- );
1159
- await revokeInvite(token, membershipKey, companyUid);
1160
- console.log(chalk.green(`Revoked invite '${membershipKey}'`));
1161
- } catch (err) {
1162
- if (err instanceof InviteHttpError) {
1163
- const msg =
1164
- err.status === 403
1165
- ? err.code === "ADMIN_ROLE_TARGET_RESTRICTED"
1166
- ? "Admin can only revoke role=member; ask an owner to revoke this membership"
1167
- : "Not authorized — only admins and owners can revoke invites"
1168
- : err.status === 404
1169
- ? "Invite not found — it may have already been accepted or revoked"
1170
- : formatInviteHttpError(err.status, err.message, err.code);
1171
- console.error(chalk.red(msg));
1172
- process.exit(1);
1173
- }
1174
- console.error(
1175
- chalk.red("Error:"),
1176
- err instanceof Error ? err.message : String(err),
1177
- );
1178
- process.exit(1);
1179
- }
1180
- });
1181
- }