@indigoai-us/hq-cli 5.38.1 → 5.38.2

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,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.38.2]
6
+
7
+ ### Fixed
8
+
9
+ - **`--company <slug>` no longer strands a user when two tenants share a slug
10
+ (DEV-1742 / feedback_a7e5abd9).** Under hq-pro's per-user-namespace model two
11
+ unrelated tenants may each legitimately hold the same company slug. The shared
12
+ company resolver resolved `--company` through the **global** `/entity/by-slug`
13
+ lookup, which `409`s on a collision and told the user to "disambiguate by
14
+ uid" — but passing that uid back to `--company` was fed into the same by-slug
15
+ path and `404`d, leaving no working escape (the dead-end behind the
16
+ liverecover duplicate). The resolver now resolves a slug **within the caller's
17
+ namespace first** via `/entity/check-slug/me` — within one caller's world a
18
+ slug is unique (enforced by hq-pro at create/accept/claim), so the caller's
19
+ own company resolves cleanly and a stranger's same-slug company is invisible.
20
+ Falls back to the global lookup for cross-company/admin resolutions; a residual
21
+ multi-match the caller doesn't belong to now yields an actionable
22
+ `--company <uid>` message; a `cmp_` uid resolves directly as a silent escape
23
+ hatch. One shared resolver, so this fixes `files`, `members`, `groups`,
24
+ `group-grants`, `meetings`, and `secrets` at once.
25
+
26
+ ### Changed
27
+
28
+ - **`hq onboard create-company` guidance** now steers a would-be joiner toward
29
+ `hq onboard join`, so people stop minting a separate same-named company they're
30
+ alone in. No create-time behavior change.
31
+
5
32
  ## [5.38.0]
6
33
 
7
34
  ### Fixed
@@ -19,7 +19,7 @@
19
19
  * browser-OAuth flow opens automatically.
20
20
  */
21
21
 
22
- !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]="8f6c7f48-0ce5-55f3-bfd9-b4a6040adb0b")}catch(e){}}();
22
+ !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]="99e9ce57-1051-59ce-8897-edcc83e83a16")}catch(e){}}();
23
23
  import chalk from "chalk";
24
24
  import { runOnboardCli, readCheckpoint } from "@indigoai-us/hq-onboarding";
25
25
  import { DEFAULT_HQ_ROOT, DEFAULT_VAULT_API_URL, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
@@ -34,7 +34,9 @@ export function registerOnboardCommand(program) {
34
34
  .description("Provision an HQ vault: sign in, create company, S3 bucket, STS, sync");
35
35
  onboard
36
36
  .command("create-company")
37
- .description("Sign in and provision a brand new HQ vault for a company")
37
+ .description("Sign in and provision a brand NEW HQ vault for a company. To join a " +
38
+ "teammate's EXISTING company instead, use `hq onboard join` — creating " +
39
+ "a same-named company makes a separate one you'd be alone in.")
38
40
  .requiredOption("--slug <slug>", "Company slug (used as bucket name suffix)")
39
41
  .requiredOption("--name <name>", "Company display name")
40
42
  .requiredOption("--email <email>", "Your email (must match Cognito sign-in)")
@@ -46,6 +48,9 @@ export function registerOnboardCommand(program) {
46
48
  console.log(` Company: ${options.name} (${options.slug})`);
47
49
  console.log(` Person: ${options.personName} <${options.email}>`);
48
50
  console.log(` HQ root: ${options.hqRoot}\n`);
51
+ console.log(chalk.gray(" This creates a NEW company you own. Joining a teammate's existing\n" +
52
+ " company? Stop and run `hq onboard join --invite-token <token>` —\n" +
53
+ " creating a same-named company leaves you alone in a separate one.\n"));
49
54
  const accessToken = await ensureCognitoToken();
50
55
  const result = await runOnboardCli({
51
56
  mode: "create-company",
@@ -174,4 +179,4 @@ export function registerOnboardCommand(program) {
174
179
  });
175
180
  }
176
181
  //# sourceMappingURL=onboard.js.map
177
- //# debugId=8f6c7f48-0ce5-55f3-bfd9-b4a6040adb0b
182
+ //# debugId=99e9ce57-1051-59ce-8897-edcc83e83a16
@@ -17,6 +17,7 @@ export declare function vaultApiFetchPublic(opts: {
17
17
  path: string;
18
18
  query?: Record<string, string>;
19
19
  }): Promise<Response>;
20
+ export declare function looksLikeCompanyUid(ref: string): boolean;
20
21
  export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
21
22
  export declare function resolveCallerPersonUid(token: string): Promise<string>;
22
23
  export declare function getEntityUid(token: string, opts: {
@@ -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]="bdd04f0a-8d43-5072-916d-a801a2f25731")}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]="85e24f6f-4027-5b00-a4a4-b9a140897d7e")}catch(e){}}();
3
3
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
4
4
  import { Sentry } from '../sentry.js';
5
5
  export async function vaultApiFetch(opts) {
@@ -72,14 +72,98 @@ export async function vaultApiFetchPublic(opts) {
72
72
  }
73
73
  return response;
74
74
  }
75
- async function resolveCompanyUid(token, slug) {
75
+ /**
76
+ * A `--company` value beginning with `cmp_` is a company UID (globally unique
77
+ * by ULID), not a slug. Recognised so the resolver can route it directly by
78
+ * uid — a silent escape hatch, NOT the path users are expected to type.
79
+ * Mirrors hq-cloud's `KNOWN_UID_PREFIXES` / `context.ts` dispatch so the two
80
+ * CLI codebases agree on the uid shape.
81
+ */
82
+ const COMPANY_UID_PREFIX = 'cmp_';
83
+ export function looksLikeCompanyUid(ref) {
84
+ return ref.startsWith(COMPANY_UID_PREFIX);
85
+ }
86
+ async function resolveCompanyByUid(token, uid) {
76
87
  const res = await vaultApiFetch({
77
88
  token,
78
- path: `/entity/by-slug/company/${encodeURIComponent(slug)}`,
89
+ path: `/entity/${encodeURIComponent(uid)}`,
79
90
  });
80
91
  if (!res.ok) {
81
- const body = await res.json().catch(() => ({}));
82
- throw new Error(`Failed to resolve company slug '${slug}': ${body.error ?? res.statusText}`);
92
+ const body = (await res.json().catch(() => ({})));
93
+ throw new Error(`Failed to resolve company '${uid}': ${body.error ?? res.statusText}`);
94
+ }
95
+ const data = (await res.json());
96
+ return data.entity.uid;
97
+ }
98
+ /**
99
+ * Resolve a company slug WITHIN THE CALLER'S NAMESPACE via the server's
100
+ * caller-scoped `/entity/check-slug/me` endpoint. Returns the caller's own
101
+ * company uid for that slug, or `null` when the caller owns / actively belongs
102
+ * to no company with that slug.
103
+ *
104
+ * This is the load-bearing fix for DEV-1742. Under hq-pro's per-user-namespace
105
+ * model two unrelated tenants may each hold "acme", so the legacy GLOBAL
106
+ * `/entity/by-slug` lookup 409s `SlugNotUniqueError` on a collision and strands
107
+ * the caller — exactly how the liverecover duplicate stayed unreachable. But
108
+ * within ONE caller's world a slug IS unique: hq-pro blocks a second same-slug
109
+ * company from entering a caller's namespace at every seam — create
110
+ * (`findSlugInPersonNamespace` in POST /entity), accept (POST /membership/accept
111
+ * → SlugInUserNamespaceError), and claim (claimByEmail skips the colliding
112
+ * invite). So "the caller's company called <slug>" is unambiguous, and the
113
+ * stranger's same-slug company is correctly invisible to resolution. This is
114
+ * the same primitive hq-cloud's `context.ts` already resolves slugs through.
115
+ */
116
+ async function resolveSlugInCallerNamespace(token, slug) {
117
+ const res = await vaultApiFetch({
118
+ token,
119
+ path: '/entity/check-slug/me',
120
+ query: { type: 'company', slug },
121
+ });
122
+ if (!res.ok) {
123
+ // Namespace lookup unavailable (e.g. membership table not configured →
124
+ // 503, or the caller has no person entity). Signal "couldn't resolve here"
125
+ // and let the caller fall back to the global lookup. vaultApiFetch already
126
+ // recorded the non-2xx as a Sentry breadcrumb, so this is not a silent
127
+ // swallow.
128
+ return null;
129
+ }
130
+ const data = (await res.json());
131
+ // `available: false` carries the caller's own company holding the slug.
132
+ return data.conflictingCompanyUid ?? null;
133
+ }
134
+ async function resolveCompanyUid(token, ref) {
135
+ // Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
136
+ if (looksLikeCompanyUid(ref)) {
137
+ return resolveCompanyByUid(token, ref);
138
+ }
139
+ // PRIMARY PATH — caller-scoped slug resolution. Resolves the slug to the
140
+ // caller's OWN company (unique within their namespace by the invariant
141
+ // above), making a stranger's same-slug company invisible.
142
+ const mine = await resolveSlugInCallerNamespace(token, ref);
143
+ if (mine) {
144
+ return mine;
145
+ }
146
+ // The caller owns / belongs to no company with this slug. Fall back to the
147
+ // GLOBAL by-slug lookup for the cross-company / admin resolutions that
148
+ // intentionally target a company the caller isn't a member of (e.g. a
149
+ // group-grant target). A single global match resolves cleanly.
150
+ const res = await vaultApiFetch({
151
+ token,
152
+ path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
153
+ });
154
+ if (!res.ok) {
155
+ const body = (await res.json().catch(() => ({})));
156
+ // Residual true ambiguity: the slug matches multiple live companies and
157
+ // NONE is in the caller's namespace (server returns 409 SlugNotUniqueError
158
+ // with the colliding `uids`). The caller-scoped path can't pick one, so
159
+ // tell them exactly how — re-run with `--company <uid>` — and list the
160
+ // candidates, instead of echoing the generic server message.
161
+ if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
162
+ throw new Error(`Company slug '${ref}' matches ${body.uids.length} companies and none ` +
163
+ `is in your namespace. Re-run with --company <uid> to pick one:\n` +
164
+ body.uids.map((u) => ` --company ${u}`).join('\n'));
165
+ }
166
+ throw new Error(`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`);
83
167
  }
84
168
  const data = (await res.json());
85
169
  return data.entity.uid;
@@ -100,8 +184,10 @@ async function resolveCompanyFromMemberships(token) {
100
184
  if (active.length === 1) {
101
185
  return active[0].companyUid;
102
186
  }
103
- const uids = active.map((m) => m.companyUid).join(', ');
104
- throw new Error(`Multiple companies found (${uids}). Use --company <slug> to specify which one.`);
187
+ const uids = active.map((m) => m.companyUid);
188
+ throw new Error(`Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
189
+ `pick one:\n` +
190
+ uids.map((u) => ` --company ${u}`).join('\n'));
105
191
  }
106
192
  export async function getCompanyUid(token, companySlug) {
107
193
  if (companySlug) {
@@ -143,4 +229,4 @@ export async function getEntityUid(token, opts) {
143
229
  return getCompanyUid(token, opts.companySlug);
144
230
  }
145
231
  //# sourceMappingURL=vault-api.js.map
146
- //# debugId=bdd04f0a-8d43-5072-916d-a801a2f25731
232
+ //# debugId=85e24f6f-4027-5b00-a4a4-b9a140897d7e
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.38.1",
3
+ "version": "5.38.2",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -43,7 +43,11 @@ export function registerOnboardCommand(program: Command): void {
43
43
 
44
44
  onboard
45
45
  .command("create-company")
46
- .description("Sign in and provision a brand new HQ vault for a company")
46
+ .description(
47
+ "Sign in and provision a brand NEW HQ vault for a company. To join a " +
48
+ "teammate's EXISTING company instead, use `hq onboard join` — creating " +
49
+ "a same-named company makes a separate one you'd be alone in.",
50
+ )
47
51
  .requiredOption("--slug <slug>", "Company slug (used as bucket name suffix)")
48
52
  .requiredOption("--name <name>", "Company display name")
49
53
  .requiredOption("--email <email>", "Your email (must match Cognito sign-in)")
@@ -65,6 +69,13 @@ export function registerOnboardCommand(program: Command): void {
65
69
  console.log(` Company: ${options.name} (${options.slug})`);
66
70
  console.log(` Person: ${options.personName} <${options.email}>`);
67
71
  console.log(` HQ root: ${options.hqRoot}\n`);
72
+ console.log(
73
+ chalk.gray(
74
+ " This creates a NEW company you own. Joining a teammate's existing\n" +
75
+ " company? Stop and run `hq onboard join --invite-token <token>` —\n" +
76
+ " creating a same-named company leaves you alone in a separate one.\n",
77
+ ),
78
+ );
68
79
 
69
80
  const accessToken = await ensureCognitoToken();
70
81
  const result = await runOnboardCli({
@@ -85,14 +85,98 @@ describe('getEntityUid', () => {
85
85
  expect(url).toMatch(/\/entity\/by-type\/person/);
86
86
  });
87
87
 
88
- it('routes to company-slug resolution when companySlug is set', async () => {
88
+ it('resolves a slug CALLER-SCOPED — to the caller\'s own company, ignoring a stranger\'s same-slug company (DEV-1742)', async () => {
89
+ // The slug resolves within the caller's namespace via check-slug/me, which
90
+ // returns the caller's OWN company. A stranger's same-slug company is
91
+ // invisible — no by-slug 409, no stranding.
89
92
  fetchMock.mockResolvedValueOnce(
90
- mockResponse(200, { entity: { uid: 'cmp_acme' } }),
93
+ mockResponse(200, { available: false, conflictingCompanyUid: 'cmp_mine' }),
91
94
  );
92
95
  const uid = await getEntityUid('tok', { companySlug: 'acme' });
93
- expect(uid).toBe('cmp_acme');
96
+ expect(uid).toBe('cmp_mine');
94
97
  const url = fetchMock.mock.calls[0][0] as string;
95
- expect(url).toMatch(/\/entity\/by-slug\/company\/acme/);
98
+ expect(url).toMatch(/\/entity\/check-slug\/me/);
99
+ // The caller-scoped hit is authoritative — never falls through to by-slug.
100
+ expect(fetchMock.mock.calls.length).toBe(1);
101
+ });
102
+
103
+ it('falls back to the global by-slug lookup when the slug is not in the caller\'s namespace', async () => {
104
+ fetchMock
105
+ .mockResolvedValueOnce(mockResponse(200, { available: true }))
106
+ .mockResolvedValueOnce(mockResponse(200, { entity: { uid: 'cmp_other' } }));
107
+ const uid = await getEntityUid('tok', { companySlug: 'other-co' });
108
+ expect(uid).toBe('cmp_other');
109
+ expect(fetchMock.mock.calls[0][0]).toMatch(/\/entity\/check-slug\/me/);
110
+ expect(fetchMock.mock.calls[1][0]).toMatch(/\/entity\/by-slug\/company\/other-co/);
111
+ });
112
+
113
+ it('resolves a cmp_ uid DIRECTLY by uid, never via by-slug (silent escape hatch)', async () => {
114
+ fetchMock.mockResolvedValueOnce(
115
+ mockResponse(200, { entity: { uid: 'cmp_01ABC' } }),
116
+ );
117
+ const uid = await getEntityUid('tok', { companySlug: 'cmp_01ABC' });
118
+ expect(uid).toBe('cmp_01ABC');
119
+ const url = fetchMock.mock.calls[0][0] as string;
120
+ expect(url).toMatch(/\/entity\/cmp_01ABC$/);
121
+ expect(url).not.toMatch(/by-slug/);
122
+ expect(url).not.toMatch(/check-slug/);
123
+ });
124
+
125
+ it('surfaces a clean error when a cmp_ uid does not resolve', async () => {
126
+ fetchMock.mockResolvedValueOnce(
127
+ mockResponse(404, { error: 'Entity not found', uid: 'cmp_nope' }),
128
+ );
129
+ await expect(
130
+ getEntityUid('tok', { companySlug: 'cmp_nope' }),
131
+ ).rejects.toThrow(/Failed to resolve company 'cmp_nope'/);
132
+ });
133
+
134
+ it('falls through to by-slug when the namespace check is unavailable (503)', async () => {
135
+ fetchMock
136
+ .mockResolvedValueOnce(mockResponse(503, { error: 'Membership table not configured' }))
137
+ .mockResolvedValueOnce(mockResponse(200, { entity: { uid: 'cmp_legacy' } }));
138
+ const uid = await getEntityUid('tok', { companySlug: 'acme' });
139
+ expect(uid).toBe('cmp_legacy');
140
+ expect(fetchMock.mock.calls[1][0]).toMatch(/\/entity\/by-slug\/company\/acme/);
141
+ });
142
+
143
+ it('on residual global ambiguity (not in namespace) gives actionable --company <uid> guidance', async () => {
144
+ // Caller belongs to no "acme"; the slug matches multiple strangers'
145
+ // companies. The server 409s with the colliding uids and the CLI must tell
146
+ // the user exactly how to disambiguate.
147
+ fetchMock
148
+ .mockResolvedValueOnce(mockResponse(200, { available: true }))
149
+ .mockResolvedValueOnce(
150
+ mockResponse(409, {
151
+ error:
152
+ 'Slug "acme" of type "company" matches 2 live entities — caller must disambiguate by uid or ownerUid',
153
+ type: 'company',
154
+ slug: 'acme',
155
+ uids: ['cmp_one', 'cmp_two'],
156
+ }),
157
+ );
158
+ const err = await getEntityUid('tok', { companySlug: 'acme' }).catch(
159
+ (e: Error) => e,
160
+ );
161
+ expect(err).toBeInstanceOf(Error);
162
+ expect((err as Error).message).toMatch(/matches 2 companies/);
163
+ expect((err as Error).message).toMatch(/--company cmp_one/);
164
+ expect((err as Error).message).toMatch(/--company cmp_two/);
165
+ });
166
+
167
+ it('lists candidate companies when membership fallback is ambiguous', async () => {
168
+ fetchMock.mockResolvedValueOnce(
169
+ mockResponse(200, {
170
+ memberships: [
171
+ { companyUid: 'cmp_a', role: 'member', status: 'active', membershipKey: 'k1' },
172
+ { companyUid: 'cmp_b', role: 'owner', status: 'active', membershipKey: 'k2' },
173
+ ],
174
+ }),
175
+ );
176
+ const err = await getEntityUid('tok', {}).catch((e: Error) => e);
177
+ expect(err).toBeInstanceOf(Error);
178
+ expect((err as Error).message).toMatch(/--company cmp_a/);
179
+ expect((err as Error).message).toMatch(/--company cmp_b/);
96
180
  });
97
181
 
98
182
  it('falls back to membership lookup when neither personal nor slug is set', async () => {
@@ -91,15 +91,118 @@ interface MembershipEntry {
91
91
  membershipKey: string;
92
92
  }
93
93
 
94
- async function resolveCompanyUid(token: string, slug: string): Promise<string> {
94
+ /**
95
+ * A `--company` value beginning with `cmp_` is a company UID (globally unique
96
+ * by ULID), not a slug. Recognised so the resolver can route it directly by
97
+ * uid — a silent escape hatch, NOT the path users are expected to type.
98
+ * Mirrors hq-cloud's `KNOWN_UID_PREFIXES` / `context.ts` dispatch so the two
99
+ * CLI codebases agree on the uid shape.
100
+ */
101
+ const COMPANY_UID_PREFIX = 'cmp_';
102
+
103
+ export function looksLikeCompanyUid(ref: string): boolean {
104
+ return ref.startsWith(COMPANY_UID_PREFIX);
105
+ }
106
+
107
+ async function resolveCompanyByUid(token: string, uid: string): Promise<string> {
95
108
  const res = await vaultApiFetch({
96
109
  token,
97
- path: `/entity/by-slug/company/${encodeURIComponent(slug)}`,
110
+ path: `/entity/${encodeURIComponent(uid)}`,
98
111
  });
99
112
  if (!res.ok) {
100
- const body = await res.json().catch(() => ({}));
113
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
114
+ throw new Error(
115
+ `Failed to resolve company '${uid}': ${body.error ?? res.statusText}`,
116
+ );
117
+ }
118
+ const data = (await res.json()) as { entity: { uid: string } };
119
+ return data.entity.uid;
120
+ }
121
+
122
+ /**
123
+ * Resolve a company slug WITHIN THE CALLER'S NAMESPACE via the server's
124
+ * caller-scoped `/entity/check-slug/me` endpoint. Returns the caller's own
125
+ * company uid for that slug, or `null` when the caller owns / actively belongs
126
+ * to no company with that slug.
127
+ *
128
+ * This is the load-bearing fix for DEV-1742. Under hq-pro's per-user-namespace
129
+ * model two unrelated tenants may each hold "acme", so the legacy GLOBAL
130
+ * `/entity/by-slug` lookup 409s `SlugNotUniqueError` on a collision and strands
131
+ * the caller — exactly how the liverecover duplicate stayed unreachable. But
132
+ * within ONE caller's world a slug IS unique: hq-pro blocks a second same-slug
133
+ * company from entering a caller's namespace at every seam — create
134
+ * (`findSlugInPersonNamespace` in POST /entity), accept (POST /membership/accept
135
+ * → SlugInUserNamespaceError), and claim (claimByEmail skips the colliding
136
+ * invite). So "the caller's company called <slug>" is unambiguous, and the
137
+ * stranger's same-slug company is correctly invisible to resolution. This is
138
+ * the same primitive hq-cloud's `context.ts` already resolves slugs through.
139
+ */
140
+ async function resolveSlugInCallerNamespace(
141
+ token: string,
142
+ slug: string,
143
+ ): Promise<string | null> {
144
+ const res = await vaultApiFetch({
145
+ token,
146
+ path: '/entity/check-slug/me',
147
+ query: { type: 'company', slug },
148
+ });
149
+ if (!res.ok) {
150
+ // Namespace lookup unavailable (e.g. membership table not configured →
151
+ // 503, or the caller has no person entity). Signal "couldn't resolve here"
152
+ // and let the caller fall back to the global lookup. vaultApiFetch already
153
+ // recorded the non-2xx as a Sentry breadcrumb, so this is not a silent
154
+ // swallow.
155
+ return null;
156
+ }
157
+ const data = (await res.json()) as {
158
+ available?: boolean;
159
+ conflictingCompanyUid?: string;
160
+ };
161
+ // `available: false` carries the caller's own company holding the slug.
162
+ return data.conflictingCompanyUid ?? null;
163
+ }
164
+
165
+ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
166
+ // Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
167
+ if (looksLikeCompanyUid(ref)) {
168
+ return resolveCompanyByUid(token, ref);
169
+ }
170
+
171
+ // PRIMARY PATH — caller-scoped slug resolution. Resolves the slug to the
172
+ // caller's OWN company (unique within their namespace by the invariant
173
+ // above), making a stranger's same-slug company invisible.
174
+ const mine = await resolveSlugInCallerNamespace(token, ref);
175
+ if (mine) {
176
+ return mine;
177
+ }
178
+
179
+ // The caller owns / belongs to no company with this slug. Fall back to the
180
+ // GLOBAL by-slug lookup for the cross-company / admin resolutions that
181
+ // intentionally target a company the caller isn't a member of (e.g. a
182
+ // group-grant target). A single global match resolves cleanly.
183
+ const res = await vaultApiFetch({
184
+ token,
185
+ path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
186
+ });
187
+ if (!res.ok) {
188
+ const body = (await res.json().catch(() => ({}))) as {
189
+ error?: string;
190
+ uids?: string[];
191
+ };
192
+ // Residual true ambiguity: the slug matches multiple live companies and
193
+ // NONE is in the caller's namespace (server returns 409 SlugNotUniqueError
194
+ // with the colliding `uids`). The caller-scoped path can't pick one, so
195
+ // tell them exactly how — re-run with `--company <uid>` — and list the
196
+ // candidates, instead of echoing the generic server message.
197
+ if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
198
+ throw new Error(
199
+ `Company slug '${ref}' matches ${body.uids.length} companies and none ` +
200
+ `is in your namespace. Re-run with --company <uid> to pick one:\n` +
201
+ body.uids.map((u) => ` --company ${u}`).join('\n'),
202
+ );
203
+ }
101
204
  throw new Error(
102
- `Failed to resolve company slug '${slug}': ${(body as Record<string, string>).error ?? res.statusText}`,
205
+ `Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`,
103
206
  );
104
207
  }
105
208
  const data = (await res.json()) as { entity: { uid: string } };
@@ -122,9 +225,11 @@ async function resolveCompanyFromMemberships(token: string): Promise<string> {
122
225
  if (active.length === 1) {
123
226
  return active[0].companyUid;
124
227
  }
125
- const uids = active.map((m) => m.companyUid).join(', ');
228
+ const uids = active.map((m) => m.companyUid);
126
229
  throw new Error(
127
- `Multiple companies found (${uids}). Use --company <slug> to specify which one.`,
230
+ `Multiple active companies found. Re-run with --company <slug-or-uid> to ` +
231
+ `pick one:\n` +
232
+ uids.map((u) => ` --company ${u}`).join('\n'),
128
233
  );
129
234
  }
130
235