@indigoai-us/hq-cli 5.52.0 → 5.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.53.1]
6
+
7
+ ### Changed
8
+
9
+ - **`hq members promote` now clarifies admin access.** Promoting someone to
10
+ `owner` or `admin` prints that they automatically have full read and write
11
+ access to all of the company's context — there is no separate context grant to
12
+ give them (owners/admins resolve to full access via the server-side role
13
+ bypass) — and that what they actually see synced locally is governed by their
14
+ sync mode (`hq sync mode`), not by access. Member/guest promotions are
15
+ unchanged.
16
+
17
+ ## [5.53.0]
18
+
19
+ ### Added
20
+
21
+ - **`hq crm entity upsert` — write a client record into the native CRM from the
22
+ terminal.** Posts to `POST /crm/entities`, which runs through the ontology
23
+ write-gate (tenant-isolated, `crmEnabled`-gated) and refreshes the company's
24
+ CRM projection. `--company`, `--type` (`contact|deal|contract|invoice|company`)
25
+ and `--name` are required in flag form; `--attio-id|--stripe-id|--pandadoc-id|
26
+ --neon-id` populate `external_ids`; `--json <file|->` passes a full entity or
27
+ array instead. Surfaces a `CRM_DISABLED` 403 with an enable hint. This is the
28
+ build-native path for companies with no external CRM.
29
+ - **`hq company settings set` — toggle company settings from the CLI.** Calls
30
+ `PUT /company-settings` with `--crm-enabled <true|false>` and/or
31
+ `--ontology-enabled <true|false>` (at least one required; owner-gated
32
+ server-side). `--crm-enabled true` is how `/crm-setup` activates the native
33
+ CRM for a company.
34
+
5
35
  ## [5.52.0]
6
36
 
7
37
  ### Added
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerCompanyCommand(program: Command): void;
3
+ //# sourceMappingURL=company.d.ts.map
@@ -0,0 +1,82 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="c0b7f9ed-8ddb-5422-8872-f85b6b406af6")}catch(e){}}();
3
+ import chalk from "chalk";
4
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
5
+ import { vaultApiFetch, getEntityUid } from "../utils/vault-api.js";
6
+ /**
7
+ * Parse a `--flag <true|false>` string into a boolean, or throw. Commander
8
+ * passes the raw string; we validate strictly so a typo never silently writes
9
+ * the wrong value.
10
+ */
11
+ function parseBoolFlag(value, flag) {
12
+ const v = value.trim().toLowerCase();
13
+ if (v === "true")
14
+ return true;
15
+ if (v === "false")
16
+ return false;
17
+ throw new Error(`${flag} must be 'true' or 'false' (got '${value}')`);
18
+ }
19
+ export function registerCompanyCommand(program) {
20
+ const company = program
21
+ .command("company")
22
+ .description("Company-level settings")
23
+ .option("--company <slug>", "Company slug (resolves to companyUid)");
24
+ const settings = company
25
+ .command("settings")
26
+ .description("Per-company settings (owner-only)");
27
+ settings
28
+ .command("set")
29
+ .description("Set per-company settings (PUT /company-settings). Require at least one " +
30
+ "of --crm-enabled / --ontology-enabled.")
31
+ .option("--crm-enabled <true|false>", "Enable/disable the native CRM for this company")
32
+ .option("--ontology-enabled <true|false>", "Enable/disable the ontology gardener for this company")
33
+ .action(async (opts) => {
34
+ try {
35
+ const companySlug = company.opts().company;
36
+ if (!companySlug) {
37
+ console.error(chalk.red("Error: --company <slug> is required."));
38
+ process.exit(1);
39
+ }
40
+ if (opts.crmEnabled === undefined && opts.ontologyEnabled === undefined) {
41
+ console.error(chalk.red("Error: provide at least one of --crm-enabled / --ontology-enabled."));
42
+ process.exit(1);
43
+ }
44
+ const body = {};
45
+ if (opts.crmEnabled !== undefined) {
46
+ body.crmEnabled = parseBoolFlag(opts.crmEnabled, "--crm-enabled");
47
+ }
48
+ if (opts.ontologyEnabled !== undefined) {
49
+ body.ontologyEnabled = parseBoolFlag(opts.ontologyEnabled, "--ontology-enabled");
50
+ }
51
+ const token = await ensureCognitoToken();
52
+ const companyUid = await getEntityUid(token, { companySlug });
53
+ body.companyUid = companyUid;
54
+ const res = await vaultApiFetch({
55
+ token,
56
+ path: "/company-settings",
57
+ method: "PUT",
58
+ body,
59
+ });
60
+ if (!res.ok) {
61
+ const errBody = (await res.json().catch(() => ({})));
62
+ if (res.status === 403) {
63
+ console.error(chalk.red("Requires owner role on this company to change its settings."));
64
+ process.exit(1);
65
+ }
66
+ console.error(chalk.red(`Failed to update company settings: ${errBody.error ?? res.statusText}`));
67
+ process.exit(1);
68
+ }
69
+ const applied = Object.entries(body)
70
+ .filter(([k]) => k !== "companyUid")
71
+ .map(([k, v]) => `${k}=${v}`)
72
+ .join(", ");
73
+ console.log(chalk.green(`Company settings updated for ${companySlug}: ${applied}`));
74
+ }
75
+ catch (err) {
76
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
77
+ process.exit(1);
78
+ }
79
+ });
80
+ }
81
+ //# sourceMappingURL=company.js.map
82
+ //# debugId=c0b7f9ed-8ddb-5422-8872-f85b6b406af6
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerCrmCommand(program: Command): void;
3
+ //# sourceMappingURL=crm.d.ts.map
@@ -0,0 +1,162 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="056ad757-a0da-5318-b97f-21ae6529f648")}catch(e){}}();
3
+ import chalk from "chalk";
4
+ import { readFileSync } from "node:fs";
5
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
6
+ import { vaultApiFetch, getEntityUid } from "../utils/vault-api.js";
7
+ /**
8
+ * `hq crm entity upsert` — agent-facing controlled entity upsert
9
+ * (hq-native-crm US-002). Wraps `POST /crm/entities`, which fronts the ontology
10
+ * write gate so an authenticated company MEMBER (or higher) can create/update
11
+ * canonical CRM entities in their company's vault.
12
+ *
13
+ * Conventions mirror `secrets.ts`: ensureCognitoToken() → getEntityUid() →
14
+ * vaultApiFetch() → error-check → chalk output.
15
+ */
16
+ /** The CRM/ontology entity types the upsert route accepts via `--type`. */
17
+ const CRM_ENTITY_TYPES = [
18
+ "contact",
19
+ "deal",
20
+ "contract",
21
+ "invoice",
22
+ "company",
23
+ ];
24
+ /**
25
+ * Read the `--json <file|->` payload: a single entity object or an array of
26
+ * entities. `-` reads from stdin. Returns the entities array (always
27
+ * normalized to an array) or throws a descriptive Error.
28
+ */
29
+ function readJsonEntities(source) {
30
+ const raw = source === "-"
31
+ ? readFileSync(0, "utf8") // fd 0 = stdin
32
+ : readFileSync(source, "utf8");
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(raw);
36
+ }
37
+ catch (err) {
38
+ throw new Error(`--json payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
39
+ }
40
+ const entities = Array.isArray(parsed) ? parsed : [parsed];
41
+ if (entities.length === 0) {
42
+ throw new Error("--json payload contained no entities");
43
+ }
44
+ for (const e of entities) {
45
+ if (!e || typeof e !== "object") {
46
+ throw new Error("--json entities must be objects");
47
+ }
48
+ const obj = e;
49
+ if (typeof obj.type !== "string" || typeof obj.canonical_name !== "string") {
50
+ throw new Error("each --json entity needs a string `type` and `canonical_name`");
51
+ }
52
+ }
53
+ return entities;
54
+ }
55
+ /**
56
+ * Build a single entity from the flag form (`--type` + `--name` + optional
57
+ * external-id flags). The route fills the additive defaults (aliases/domain/
58
+ * relationships/confidence) when omitted, but we send a complete minimal shape.
59
+ */
60
+ function buildEntityFromFlags(opts) {
61
+ const externalIds = {};
62
+ if (opts.attioId)
63
+ externalIds.attio = opts.attioId;
64
+ if (opts.stripeId)
65
+ externalIds.stripe = opts.stripeId;
66
+ if (opts.pandadocId)
67
+ externalIds.pandadoc = opts.pandadocId;
68
+ if (opts.neonId)
69
+ externalIds.neon = opts.neonId;
70
+ const entity = {
71
+ type: opts.type,
72
+ canonical_name: opts.name,
73
+ aliases: [],
74
+ confidence: 1,
75
+ domain: [],
76
+ relationships: [],
77
+ };
78
+ if (Object.keys(externalIds).length > 0) {
79
+ entity.external_ids = externalIds;
80
+ }
81
+ return entity;
82
+ }
83
+ export function registerCrmCommand(program) {
84
+ const crm = program
85
+ .command("crm")
86
+ .description("Native CRM — upsert canonical entities into a company vault")
87
+ .option("--company <slug>", "Company slug (resolves to companyUid)");
88
+ const entity = crm
89
+ .command("entity")
90
+ .description("CRM entity operations");
91
+ entity
92
+ .command("upsert")
93
+ .description("Create or update a canonical CRM entity (POST /crm/entities). " +
94
+ "Use --type/--name (+ optional external-id flags) or --json <file|-> " +
95
+ "to pass a full entity/array.")
96
+ .option("--type <type>", `Entity type: ${CRM_ENTITY_TYPES.join(" | ")}`)
97
+ .option("--name <canonical_name>", "Canonical entity name")
98
+ .option("--attio-id <id>", "Attio record id → external_ids.attio")
99
+ .option("--stripe-id <id>", "Stripe object id → external_ids.stripe")
100
+ .option("--pandadoc-id <id>", "PandaDoc document id → external_ids.pandadoc")
101
+ .option("--neon-id <id>", "Neon row id → external_ids.neon")
102
+ .option("--json <file|->", "Path to a JSON file (or '-' for stdin) with a full entity object or array")
103
+ .action(async (opts) => {
104
+ try {
105
+ const companySlug = crm.opts().company;
106
+ if (!companySlug) {
107
+ console.error(chalk.red("Error: --company <slug> is required."));
108
+ process.exit(1);
109
+ }
110
+ // Resolve the entity payload: --json is the alternative to flags.
111
+ let entities;
112
+ if (opts.json) {
113
+ entities = readJsonEntities(opts.json);
114
+ }
115
+ else {
116
+ if (!opts.type) {
117
+ console.error(chalk.red(`Error: --type <${CRM_ENTITY_TYPES.join("|")}> is required (or pass --json).`));
118
+ process.exit(1);
119
+ }
120
+ if (!CRM_ENTITY_TYPES.includes(opts.type)) {
121
+ console.error(chalk.red(`Error: --type must be one of ${CRM_ENTITY_TYPES.join(", ")}.`));
122
+ process.exit(1);
123
+ }
124
+ if (!opts.name) {
125
+ console.error(chalk.red("Error: --name <canonical_name> is required (or pass --json)."));
126
+ process.exit(1);
127
+ }
128
+ entities = [buildEntityFromFlags(opts)];
129
+ }
130
+ const token = await ensureCognitoToken();
131
+ const companyUid = await getEntityUid(token, { companySlug });
132
+ const res = await vaultApiFetch({
133
+ token,
134
+ path: "/crm/entities",
135
+ method: "POST",
136
+ body: { companyUid, entities },
137
+ });
138
+ if (!res.ok) {
139
+ const body = (await res.json().catch(() => ({})));
140
+ // CrmDisabledError surfaces as a 403 with a helpful message.
141
+ if (res.status === 403 && body.code === "CRM_DISABLED") {
142
+ console.error(chalk.red("CRM is not enabled for this company. Enable it first: " +
143
+ `hq company settings set --company ${companySlug} --crm-enabled true`));
144
+ process.exit(1);
145
+ }
146
+ console.error(chalk.red(`Failed to upsert entities: ${body.error ?? res.statusText}`));
147
+ process.exit(1);
148
+ }
149
+ const result = (await res.json().catch(() => ({})));
150
+ console.log(chalk.green(`Upsert ${result.status ?? "ok"}: ` +
151
+ `${result.created ?? 0} created, ` +
152
+ `${result.updated ?? 0} updated, ` +
153
+ `${result.skipped ?? 0} skipped.`));
154
+ }
155
+ catch (err) {
156
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
157
+ process.exit(1);
158
+ }
159
+ });
160
+ }
161
+ //# sourceMappingURL=crm.js.map
162
+ //# debugId=056ad757-a0da-5318-b97f-21ae6529f648
@@ -66,6 +66,8 @@ interface RunFilesDeleteParams {
66
66
  prefix: string;
67
67
  dryRun: boolean;
68
68
  yes: boolean;
69
+ /** Target the caller's personal vault instead of a company vault. */
70
+ personal?: boolean;
69
71
  companySlug: string | undefined;
70
72
  }
71
73
  /**
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="fc999359-c426-5277-ba96-5452ae1ed2e2")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="af29c08f-dbeb-5406-8deb-fdc263354b4a")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import open from "open";
5
5
  import * as readline from "node:readline";
@@ -356,16 +356,24 @@ export function registerFilesCommand(program) {
356
356
  });
357
357
  files
358
358
  .command("delete <prefix>")
359
- .description("Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes.")
359
+ .description("Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes. Use --personal to target your own personal vault instead of a company.")
360
360
  .option("--dry-run", "List what WOULD be deleted without deleting anything")
361
+ .option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
361
362
  .option("-y, --yes", "Skip the confirmation prompt (for scripts)")
362
363
  .action(async (prefix, opts) => {
363
364
  try {
365
+ const companySlug = files.opts().company;
366
+ const personal = opts.personal === true;
367
+ if (personal && companySlug) {
368
+ console.error(chalk.red("Pass either --personal or --company, not both."));
369
+ process.exit(1);
370
+ }
364
371
  await runFilesDelete({
365
372
  prefix,
366
373
  dryRun: opts.dryRun === true,
367
374
  yes: opts.yes === true,
368
- companySlug: files.opts().company,
375
+ personal,
376
+ companySlug,
369
377
  });
370
378
  }
371
379
  catch (err) {
@@ -563,17 +571,28 @@ function realConfirm(message) {
563
571
  /**
564
572
  * POST /v1/files/delete. Throws FilesDeleteHttpError on any non-2xx so the one
565
573
  * caller renders a single consistent error path.
574
+ *
575
+ * Scope is EITHER a company vault (`companyUid` set) OR the caller's personal
576
+ * vault (`personal: true`). For the personal case the server resolves the target
577
+ * person + bucket from the authenticated caller — we send NO uid, just the
578
+ * `personal` flag — so there is nothing for the client to get wrong or spoof.
566
579
  */
567
580
  async function callDeleteEndpoint(params) {
568
581
  const res = await vaultApiFetch({
569
582
  token: params.token,
570
583
  path: "/v1/files/delete",
571
584
  method: "POST",
572
- body: {
573
- company: params.companyUid,
574
- prefix: params.prefix,
575
- dryRun: params.dryRun,
576
- },
585
+ body: params.personal
586
+ ? {
587
+ personal: true,
588
+ prefix: params.prefix,
589
+ dryRun: params.dryRun,
590
+ }
591
+ : {
592
+ company: params.companyUid,
593
+ prefix: params.prefix,
594
+ dryRun: params.dryRun,
595
+ },
577
596
  });
578
597
  if (!res.ok) {
579
598
  const body = (await res.json().catch(() => ({})));
@@ -642,14 +661,21 @@ export async function runFilesDelete(params, deps = {}) {
642
661
  process.exit(1);
643
662
  }
644
663
  const token = await ensureCognitoToken();
645
- const companyUid = await getCompanyUid(token, params.companySlug);
664
+ // Personal scope resolves the vault server-side from the caller's identity —
665
+ // no company to look up. Company scope resolves the companyUid as before.
666
+ const companyUid = params.personal
667
+ ? undefined
668
+ : await getCompanyUid(token, params.companySlug);
669
+ const scopeArgs = params.personal
670
+ ? { personal: true }
671
+ : { companyUid };
646
672
  // 1. Always preview first — this is how we print the EXACT key count before
647
673
  // deleting anything (and the whole behavior of --dry-run).
648
674
  let preview;
649
675
  try {
650
676
  preview = await callDeleteEndpoint({
651
677
  token,
652
- companyUid,
678
+ ...scopeArgs,
653
679
  prefix: normalized,
654
680
  dryRun: true,
655
681
  });
@@ -682,7 +708,9 @@ export async function runFilesDelete(params, deps = {}) {
682
708
  console.log(chalk.dim(` (${preview.skipped} more under this prefix you can't delete will be left untouched)`));
683
709
  }
684
710
  if (!params.yes) {
685
- const ok = await confirm(`Delete ${preview.matched} ${noun}? This removes them from the shared vault for everyone.`);
711
+ const ok = await confirm(params.personal
712
+ ? `Delete ${preview.matched} ${noun}? This removes them from your personal vault.`
713
+ : `Delete ${preview.matched} ${noun}? This removes them from the shared vault for everyone.`);
686
714
  if (!ok) {
687
715
  console.log(chalk.dim("Aborted — nothing was deleted."));
688
716
  return;
@@ -693,7 +721,7 @@ export async function runFilesDelete(params, deps = {}) {
693
721
  try {
694
722
  result = await callDeleteEndpoint({
695
723
  token,
696
- companyUid,
724
+ ...scopeArgs,
697
725
  prefix: normalized,
698
726
  dryRun: false,
699
727
  });
@@ -712,4 +740,4 @@ export async function runFilesDelete(params, deps = {}) {
712
740
  }
713
741
  }
714
742
  //# sourceMappingURL=files.js.map
715
- //# debugId=fc999359-c426-5277-ba96-5452ae1ed2e2
743
+ //# debugId=af29c08f-dbeb-5406-8deb-fdc263354b4a
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="83a54ed2-3f52-5b6f-a784-09f78f488ddd")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="c844265d-9140-55ac-8fc7-c0b2a049a0af")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { ensureCognitoToken } from "../utils/cognito-session.js";
5
5
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
@@ -316,6 +316,15 @@ export function registerMembersCommand(program) {
316
316
  const membershipKey = resolveRevokeTargetToMembershipKey(target, companyUid);
317
317
  await changeMemberRole(token, companyUid, membershipKey, role);
318
318
  console.log(chalk.green(`Updated role for '${target}' to ${role}`));
319
+ if (role === "owner" || role === "admin") {
320
+ // Clarify the common confusion: owners/admins already get full
321
+ // access to ALL company context automatically via the role bypass
322
+ // (hq-pro resolveEffectivePermission) — there is no separate context
323
+ // grant to give them. What they SEE locally is governed by their sync
324
+ // mode, not by access.
325
+ console.log(chalk.dim(`${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` +
326
+ `What they actually see synced to their machine is governed by their sync mode (\`hq sync mode\`), not by access.`));
327
+ }
319
328
  }
320
329
  catch (err) {
321
330
  if (err instanceof InviteHttpError) {
@@ -555,4 +564,4 @@ export function registerMembersCommand(program) {
555
564
  });
556
565
  }
557
566
  //# sourceMappingURL=members.js.map
558
- //# debugId=83a54ed2-3f52-5b6f-a784-09f78f488ddd
567
+ //# debugId=c844265d-9140-55ac-8fc7-c0b2a049a0af
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // MUST be first: guard the Node version before any dependency that needs a
6
6
  // Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
7
7
 
8
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="82c44c14-a953-5644-9bf2-dd2c6d487d63")}catch(e){}}();
8
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="6ad01242-c165-5be2-bb31-e929c0b38f5e")}catch(e){}}();
9
9
  import "./node-preflight.js";
10
10
  import { Command } from "commander";
11
11
  import { initSentry, Sentry } from "./sentry.js";
@@ -48,6 +48,8 @@ import { registerSignalsCommand } from "./commands/signals.js";
48
48
  import { registerReindexCommand } from "./commands/reindex.js";
49
49
  import { registerRescueCommand } from "./commands/rescue.js";
50
50
  import { registerMcpCommand } from "./commands/mcp-status.js";
51
+ import { registerCrmCommand } from "./commands/crm.js";
52
+ import { registerCompanyCommand } from "./commands/company.js";
51
53
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
52
54
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
53
55
  import { isEpipe } from "./utils/epipe.js";
@@ -173,6 +175,13 @@ registerRescueCommand(program);
173
175
  // provenance-based status across BOTH Claude + Codex runtimes (reads `_hqPack`
174
176
  // off the configs, NOT linkStatus), with secret-redacted output + `--json`.
175
177
  registerMcpCommand(program);
178
+ // Native CRM entity upsert (subcommand group — `hq crm entity upsert`). Wraps
179
+ // POST /crm/entities (the ontology write gate) so an authenticated company
180
+ // member can create/update canonical CRM entities in the company vault.
181
+ registerCrmCommand(program);
182
+ // Company settings (subcommand group — `hq company settings set`). Owner-only
183
+ // toggles for crmEnabled / ontologyEnabled via PUT /company-settings.
184
+ registerCompanyCommand(program);
176
185
  (async () => {
177
186
  try {
178
187
  Sentry.addBreadcrumb({
@@ -233,4 +242,4 @@ registerMcpCommand(program);
233
242
  }
234
243
  })();
235
244
  //# sourceMappingURL=index.js.map
236
- //# debugId=82c44c14-a953-5644-9bf2-dd2c6d487d63
245
+ //# debugId=6ad01242-c165-5be2-bb31-e929c0b38f5e
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.52.0",
3
+ "version": "5.53.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Unit tests for `hq company settings set` (company.ts).
3
+ *
4
+ * Mirrors members.test.ts: mock ensureCognitoToken + getEntityUid, spy on
5
+ * global fetch, drive through a Commander program, assert the PUT
6
+ * /company-settings request shape + exit behavior.
7
+ */
8
+
9
+ import { Command } from "commander";
10
+ import {
11
+ afterEach,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ it,
16
+ vi,
17
+ type MockInstance,
18
+ } from "vitest";
19
+
20
+ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
21
+ const original =
22
+ await importOriginal<typeof import("../utils/cognito-session.js")>();
23
+ return {
24
+ ...original,
25
+ ensureCognitoToken: vi.fn(async () => "test-token"),
26
+ };
27
+ });
28
+
29
+ vi.mock("../utils/vault-api.js", async (importOriginal) => {
30
+ const original = await importOriginal<typeof import("../utils/vault-api.js")>();
31
+ return {
32
+ ...original,
33
+ getEntityUid: vi.fn(async () => "cmp_acme"),
34
+ };
35
+ });
36
+
37
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
38
+ import { getEntityUid } from "../utils/vault-api.js";
39
+ import { registerCompanyCommand } from "./company.js";
40
+
41
+ function jsonResponse(status: number, body: unknown): Response {
42
+ return new Response(JSON.stringify(body), {
43
+ status,
44
+ headers: { "Content-Type": "application/json" },
45
+ });
46
+ }
47
+
48
+ let fetchSpy: MockInstance<typeof fetch>;
49
+ let exitSpy: MockInstance<typeof process.exit>;
50
+ const mockEnsureCognitoToken = vi.mocked(ensureCognitoToken);
51
+ const mockGetEntityUid = vi.mocked(getEntityUid);
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ fetchSpy = vi.spyOn(globalThis, "fetch");
56
+ mockEnsureCognitoToken.mockResolvedValue("test-token");
57
+ mockGetEntityUid.mockResolvedValue("cmp_acme");
58
+ exitSpy = vi
59
+ .spyOn(process, "exit")
60
+ .mockImplementation((code?: number) => {
61
+ throw new Error(`process.exit(${code})`);
62
+ }) as unknown as MockInstance<typeof process.exit>;
63
+ vi.spyOn(console, "log").mockImplementation(() => {});
64
+ vi.spyOn(console, "error").mockImplementation(() => {});
65
+ });
66
+
67
+ afterEach(() => {
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ function buildProgram(): Command {
72
+ const program = new Command();
73
+ program.name("hq").exitOverride();
74
+ registerCompanyCommand(program);
75
+ return program;
76
+ }
77
+
78
+ async function run(args: string[]): Promise<void> {
79
+ await buildProgram().parseAsync(["node", "hq", ...args]);
80
+ }
81
+
82
+ describe("hq company settings set", () => {
83
+ it("PUTs crmEnabled=true with the resolved companyUid", async () => {
84
+ fetchSpy.mockResolvedValueOnce(
85
+ jsonResponse(200, { companySettings: { crmEnabled: true } }),
86
+ );
87
+
88
+ await run([
89
+ "company",
90
+ "settings",
91
+ "set",
92
+ "--company",
93
+ "acme",
94
+ "--crm-enabled",
95
+ "true",
96
+ ]);
97
+
98
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
99
+ const [url, init] = fetchSpy.mock.calls[0];
100
+ expect(String(url)).toContain("/company-settings");
101
+ expect(init?.method).toBe("PUT");
102
+ const sent = JSON.parse(init?.body as string);
103
+ expect(sent).toMatchObject({ companyUid: "cmp_acme", crmEnabled: true });
104
+ expect(sent.ontologyEnabled).toBeUndefined();
105
+ });
106
+
107
+ it("PUTs both flags when both are supplied", async () => {
108
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
109
+
110
+ await run([
111
+ "company",
112
+ "settings",
113
+ "set",
114
+ "--company",
115
+ "acme",
116
+ "--crm-enabled",
117
+ "false",
118
+ "--ontology-enabled",
119
+ "true",
120
+ ]);
121
+
122
+ const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
123
+ expect(sent).toMatchObject({
124
+ companyUid: "cmp_acme",
125
+ crmEnabled: false,
126
+ ontologyEnabled: true,
127
+ });
128
+ });
129
+
130
+ it("requires at least one flag", async () => {
131
+ await expect(
132
+ run(["company", "settings", "set", "--company", "acme"]),
133
+ ).rejects.toThrow("process.exit(1)");
134
+ expect(fetchSpy).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it("requires --company", async () => {
138
+ await expect(
139
+ run(["company", "settings", "set", "--crm-enabled", "true"]),
140
+ ).rejects.toThrow("process.exit(1)");
141
+ expect(fetchSpy).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it("rejects a non-boolean flag value", async () => {
145
+ await expect(
146
+ run([
147
+ "company",
148
+ "settings",
149
+ "set",
150
+ "--company",
151
+ "acme",
152
+ "--crm-enabled",
153
+ "yes",
154
+ ]),
155
+ ).rejects.toThrow("process.exit(1)");
156
+ expect(fetchSpy).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it("exits 1 on a 403 (non-owner)", async () => {
160
+ fetchSpy.mockResolvedValueOnce(
161
+ jsonResponse(403, { error: "Requires owner role", code: "FORBIDDEN" }),
162
+ );
163
+
164
+ await expect(
165
+ run([
166
+ "company",
167
+ "settings",
168
+ "set",
169
+ "--company",
170
+ "acme",
171
+ "--crm-enabled",
172
+ "true",
173
+ ]),
174
+ ).rejects.toThrow("process.exit(1)");
175
+ expect(exitSpy).toHaveBeenCalledWith(1);
176
+ });
177
+ });