@indigoai-us/hq-cli 5.77.14 → 5.79.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 (309) hide show
  1. package/CHANGELOG.md +31 -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/meetings.js +16 -9
  31. package/dist/commands/members.js +16 -5
  32. package/dist/commands/pack-install.d.ts +14 -0
  33. package/dist/commands/pack-install.js +42 -2
  34. package/dist/commands/pkg-install.js +39 -2
  35. package/dist/commands/run.js +6 -2
  36. package/dist/commands/secrets.js +120 -65
  37. package/dist/main.js +6 -0
  38. package/dist/utils/api-key-command-gate.d.ts +45 -0
  39. package/dist/utils/api-key-command-gate.js +51 -0
  40. package/dist/utils/hq-roots.d.ts +91 -0
  41. package/dist/utils/hq-roots.js +212 -0
  42. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  43. package/dist/utils/resolve-vault-credential.js +152 -6
  44. package/dist/utils/run-bundled-script.d.ts +66 -0
  45. package/dist/utils/run-bundled-script.js +80 -0
  46. package/dist/utils/vault-api.d.ts +21 -0
  47. package/dist/utils/vault-api.js +74 -3
  48. package/package.json +7 -1
  49. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  50. package/.github/workflows/ci.yml +0 -46
  51. package/.github/workflows/publish.yml +0 -36
  52. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  53. package/e2e/cli.test.ts +0 -128
  54. package/eslint.config.js +0 -37
  55. package/pnpm-workspace.yaml +0 -4
  56. package/scripts/chmod-bins.mjs +0 -49
  57. package/scripts/generate-dsn.mjs +0 -26
  58. package/scripts/generate-scan-packages-table.mjs +0 -113
  59. package/scripts/smoke-sources-signals.sh +0 -103
  60. package/src/bin/hq-auth-refresh.ts +0 -43
  61. package/src/cli-version.ts +0 -13
  62. package/src/commands/__fixtures__/make-tar.ts +0 -126
  63. package/src/commands/_patterns.ts +0 -10
  64. package/src/commands/add.ts +0 -75
  65. package/src/commands/agents.test.ts +0 -527
  66. package/src/commands/agents.ts +0 -923
  67. package/src/commands/api-keys.test.ts +0 -291
  68. package/src/commands/api-keys.ts +0 -382
  69. package/src/commands/artifact-verify.test.ts +0 -177
  70. package/src/commands/auth.test.ts +0 -82
  71. package/src/commands/auth.ts +0 -213
  72. package/src/commands/billing.test.ts +0 -158
  73. package/src/commands/billing.ts +0 -146
  74. package/src/commands/channels.test.ts +0 -74
  75. package/src/commands/channels.ts +0 -91
  76. package/src/commands/cloud-demote.test.ts +0 -401
  77. package/src/commands/cloud-demote.ts +0 -278
  78. package/src/commands/cloud-provision.test.ts +0 -1117
  79. package/src/commands/cloud-provision.ts +0 -942
  80. package/src/commands/cloud.pull-all.test.ts +0 -711
  81. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  82. package/src/commands/cloud.push-all.test.ts +0 -485
  83. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  84. package/src/commands/cloud.selectors.test.ts +0 -208
  85. package/src/commands/cloud.status.test.ts +0 -160
  86. package/src/commands/cloud.test.ts +0 -41
  87. package/src/commands/cloud.ts +0 -2130
  88. package/src/commands/company.test.ts +0 -202
  89. package/src/commands/company.ts +0 -148
  90. package/src/commands/creators.test.ts +0 -60
  91. package/src/commands/creators.ts +0 -117
  92. package/src/commands/crm.test.ts +0 -179
  93. package/src/commands/crm.ts +0 -236
  94. package/src/commands/db-migrate.ts +0 -55
  95. package/src/commands/db-provision.ts +0 -114
  96. package/src/commands/db-sql.ts +0 -124
  97. package/src/commands/db-status.ts +0 -108
  98. package/src/commands/db.ts +0 -26
  99. package/src/commands/dm.test.ts +0 -748
  100. package/src/commands/dm.ts +0 -1226
  101. package/src/commands/feedback.test.ts +0 -413
  102. package/src/commands/feedback.ts +0 -169
  103. package/src/commands/files-browse.test.ts +0 -1249
  104. package/src/commands/files-browse.ts +0 -1506
  105. package/src/commands/files-delete.test.ts +0 -484
  106. package/src/commands/files-recovery.test.ts +0 -370
  107. package/src/commands/files.test.ts +0 -722
  108. package/src/commands/files.ts +0 -1446
  109. package/src/commands/group-grants.test.ts +0 -330
  110. package/src/commands/group-grants.ts +0 -455
  111. package/src/commands/groups.test.ts +0 -44
  112. package/src/commands/groups.ts +0 -368
  113. package/src/commands/integrations.test.ts +0 -633
  114. package/src/commands/integrations.ts +0 -507
  115. package/src/commands/list.ts +0 -85
  116. package/src/commands/login.ts +0 -52
  117. package/src/commands/logout.ts +0 -30
  118. package/src/commands/marketplace-install.test.ts +0 -543
  119. package/src/commands/marketplace-security.test.ts +0 -646
  120. package/src/commands/mcp-registration.test.ts +0 -2787
  121. package/src/commands/mcp-registration.ts +0 -2612
  122. package/src/commands/mcp-status.test.ts +0 -483
  123. package/src/commands/mcp-status.ts +0 -575
  124. package/src/commands/mcp-status.us011.test.ts +0 -243
  125. package/src/commands/meetings.test.ts +0 -522
  126. package/src/commands/meetings.ts +0 -789
  127. package/src/commands/members.test.ts +0 -1474
  128. package/src/commands/members.ts +0 -1181
  129. package/src/commands/onboard-identity-guard.test.ts +0 -99
  130. package/src/commands/onboard-identity-guard.ts +0 -103
  131. package/src/commands/onboard-join.test.ts +0 -96
  132. package/src/commands/onboard-join.ts +0 -101
  133. package/src/commands/onboard-warning.test.ts +0 -26
  134. package/src/commands/onboard-warning.ts +0 -12
  135. package/src/commands/onboard.ts +0 -267
  136. package/src/commands/outposts-heartbeat.test.ts +0 -299
  137. package/src/commands/outposts-heartbeat.ts +0 -310
  138. package/src/commands/outposts-replica-sync.test.ts +0 -272
  139. package/src/commands/outposts-self-deploy.test.ts +0 -243
  140. package/src/commands/outposts.test.ts +0 -1039
  141. package/src/commands/outposts.ts +0 -1935
  142. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  143. package/src/commands/pack-install.test.ts +0 -1469
  144. package/src/commands/pack-install.ts +0 -2256
  145. package/src/commands/pack-update-cache.test.ts +0 -149
  146. package/src/commands/packs-update-api-key.test.ts +0 -105
  147. package/src/commands/packs.test.ts +0 -88
  148. package/src/commands/packs.ts +0 -622
  149. package/src/commands/people.test.ts +0 -807
  150. package/src/commands/people.ts +0 -422
  151. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  152. package/src/commands/pkg-install.ts +0 -221
  153. package/src/commands/pkg-list.test.ts +0 -79
  154. package/src/commands/pkg-list.ts +0 -13
  155. package/src/commands/pkg-remove.ts +0 -71
  156. package/src/commands/pkg-update.ts +0 -189
  157. package/src/commands/publish.test.ts +0 -650
  158. package/src/commands/publish.ts +0 -580
  159. package/src/commands/reindex.test.ts +0 -259
  160. package/src/commands/reindex.ts +0 -274
  161. package/src/commands/rescue.test.ts +0 -39
  162. package/src/commands/rescue.ts +0 -210
  163. package/src/commands/run.env-local.test.ts +0 -88
  164. package/src/commands/run.test.ts +0 -45
  165. package/src/commands/run.ts +0 -190
  166. package/src/commands/safe-extract.test.ts +0 -459
  167. package/src/commands/safe-extract.ts +0 -444
  168. package/src/commands/secrets-scope.test.ts +0 -56
  169. package/src/commands/secrets-scope.ts +0 -32
  170. package/src/commands/secrets.parse-destination.test.ts +0 -38
  171. package/src/commands/secrets.test.ts +0 -2050
  172. package/src/commands/secrets.ts +0 -2249
  173. package/src/commands/signals.ts +0 -351
  174. package/src/commands/skill.test.ts +0 -770
  175. package/src/commands/skill.ts +0 -796
  176. package/src/commands/sources.ts +0 -362
  177. package/src/commands/sync-mode.test.ts +0 -366
  178. package/src/commands/sync-mode.ts +0 -387
  179. package/src/commands/sync-narrow.test.ts +0 -633
  180. package/src/commands/sync-narrow.ts +0 -555
  181. package/src/commands/sync.ts +0 -165
  182. package/src/commands/team-sync.ts +0 -628
  183. package/src/commands/update.ts +0 -71
  184. package/src/commands/whoami.ts +0 -83
  185. package/src/commands/workers.test.ts +0 -158
  186. package/src/commands/workers.ts +0 -301
  187. package/src/index.test.ts +0 -32
  188. package/src/index.ts +0 -20
  189. package/src/lib/db/control-plane.test.ts +0 -59
  190. package/src/lib/db/control-plane.ts +0 -113
  191. package/src/lib/db/local.test.ts +0 -81
  192. package/src/lib/db/local.ts +0 -148
  193. package/src/lib/db/migrate.test.ts +0 -133
  194. package/src/lib/db/migrate.ts +0 -137
  195. package/src/lib/db/paths.test.ts +0 -112
  196. package/src/lib/db/paths.ts +0 -128
  197. package/src/lib/db/remote-engine.test.ts +0 -44
  198. package/src/lib/db/remote-engine.ts +0 -148
  199. package/src/lib/db/remote-sql.test.ts +0 -32
  200. package/src/lib/db/remote-sql.ts +0 -62
  201. package/src/lib/db/sql.test.ts +0 -106
  202. package/src/lib/db/sql.ts +0 -192
  203. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  204. package/src/lib/local-tree-diff.test.ts +0 -301
  205. package/src/lib/local-tree-diff.ts +0 -362
  206. package/src/lib/narrow-hint-banner.test.ts +0 -235
  207. package/src/lib/narrow-hint-banner.ts +0 -212
  208. package/src/lib/plan-limit-nag.test.ts +0 -317
  209. package/src/lib/plan-limit-nag.ts +0 -264
  210. package/src/main.ts +0 -371
  211. package/src/node-preflight.test.ts +0 -60
  212. package/src/node-preflight.ts +0 -67
  213. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  214. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  215. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  216. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  217. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  218. package/src/outpost/session-heartbeat.test.ts +0 -459
  219. package/src/outpost/session-heartbeat.ts +0 -877
  220. package/src/packaging.test.ts +0 -109
  221. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  222. package/src/run/discover-schemas.test.ts +0 -153
  223. package/src/run/discover-schemas.ts +0 -79
  224. package/src/run/hq-plugin.test.ts +0 -371
  225. package/src/run/hq-plugin.ts +0 -277
  226. package/src/run/varlock-shape.test.ts +0 -57
  227. package/src/schemas/hq-package.schema.json +0 -155
  228. package/src/sentry-before-send.test.ts +0 -359
  229. package/src/sentry-before-send.ts +0 -221
  230. package/src/sentry-epipe.test.ts +0 -37
  231. package/src/sentry-release.test.ts +0 -54
  232. package/src/sentry.test.ts +0 -24
  233. package/src/sentry.ts +0 -54
  234. package/src/strategies/link.ts +0 -62
  235. package/src/strategies/merge.ts +0 -142
  236. package/src/types.ts +0 -146
  237. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  238. package/src/utils/auth-error.test.ts +0 -40
  239. package/src/utils/auth-error.ts +0 -42
  240. package/src/utils/billing-gate.test.ts +0 -95
  241. package/src/utils/billing-gate.ts +0 -228
  242. package/src/utils/breadcrumb-buffer.ts +0 -18
  243. package/src/utils/cli-telemetry.test.ts +0 -153
  244. package/src/utils/cli-telemetry.ts +0 -61
  245. package/src/utils/cognito-session.machine.test.ts +0 -109
  246. package/src/utils/cognito-session.refresh.test.ts +0 -84
  247. package/src/utils/cognito-session.test.ts +0 -328
  248. package/src/utils/cognito-session.ts +0 -448
  249. package/src/utils/company-selection-error.test.ts +0 -42
  250. package/src/utils/company-selection-error.ts +0 -45
  251. package/src/utils/contribution-table.ts +0 -83
  252. package/src/utils/environmental-error.test.ts +0 -45
  253. package/src/utils/environmental-error.ts +0 -39
  254. package/src/utils/epipe.test.ts +0 -28
  255. package/src/utils/epipe.ts +0 -29
  256. package/src/utils/expected-cli-error.test.ts +0 -28
  257. package/src/utils/expected-cli-error.ts +0 -39
  258. package/src/utils/feedback-diagnostics.test.ts +0 -183
  259. package/src/utils/feedback-diagnostics.ts +0 -123
  260. package/src/utils/feedback-screenshots.test.ts +0 -134
  261. package/src/utils/feedback-screenshots.ts +0 -124
  262. package/src/utils/feedback-versions.test.ts +0 -98
  263. package/src/utils/feedback-versions.ts +0 -68
  264. package/src/utils/git.ts +0 -74
  265. package/src/utils/integrity.ts +0 -59
  266. package/src/utils/intercepted-process-exit.test.ts +0 -37
  267. package/src/utils/intercepted-process-exit.ts +0 -36
  268. package/src/utils/login-provider.test.ts +0 -42
  269. package/src/utils/login-provider.ts +0 -30
  270. package/src/utils/manifest.test.ts +0 -167
  271. package/src/utils/manifest.ts +0 -131
  272. package/src/utils/pack-contributions.test.ts +0 -603
  273. package/src/utils/pack-contributions.ts +0 -450
  274. package/src/utils/people.ts +0 -215
  275. package/src/utils/registry-client.ts +0 -204
  276. package/src/utils/registry.ts +0 -90
  277. package/src/utils/resolve-vault-credential.test.ts +0 -69
  278. package/src/utils/resolve-vault-credential.ts +0 -60
  279. package/src/utils/sandbox-runner-client.test.ts +0 -279
  280. package/src/utils/sandbox-runner-client.ts +0 -260
  281. package/src/utils/secrets-cache.ts +0 -183
  282. package/src/utils/sentry-identity.ts +0 -45
  283. package/src/utils/settle-with-timeout.test.ts +0 -21
  284. package/src/utils/settle-with-timeout.ts +0 -22
  285. package/src/utils/user-yaml-error.test.ts +0 -24
  286. package/src/utils/user-yaml-error.ts +0 -30
  287. package/src/utils/vault-api.test.ts +0 -536
  288. package/src/utils/vault-api.ts +0 -382
  289. package/src/utils/version-check.test.ts +0 -176
  290. package/src/utils/version-check.ts +0 -155
  291. package/src/utils/version-gate.test.ts +0 -1024
  292. package/src/utils/version-gate.ts +0 -702
  293. package/test/commands/db-tenant-isolation.test.ts +0 -94
  294. package/test/commands/db.test.ts +0 -85
  295. package/test/commands/signals.test.ts +0 -207
  296. package/test/commands/sources.test.ts +0 -231
  297. package/test/e2e/smoke-install-mcp.sh +0 -113
  298. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  299. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  300. package/test/fixtures/signals/action_item/sample.md +0 -16
  301. package/test/fixtures/signals/summary/sample.md +0 -12
  302. package/test/fixtures/sources/meetings/sample.md +0 -25
  303. package/test/helpers/cli-runner.ts +0 -150
  304. package/test/helpers/s3-list-mock.ts +0 -79
  305. package/test/helpers/vault-service-mock.ts +0 -295
  306. package/test/sources-signals/smoke.test.ts +0 -228
  307. package/tsconfig.json +0 -20
  308. package/vitest.config.ts +0 -11
  309. 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
- }