@indigoai-us/hq-cli 5.47.15 → 5.47.17

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 (39) hide show
  1. package/.github/workflows/publish.yml +2 -2
  2. package/dist/commands/feedback.d.ts +2 -0
  3. package/dist/commands/feedback.js +24 -2
  4. package/dist/commands/files.d.ts +19 -0
  5. package/dist/commands/files.js +37 -3
  6. package/dist/commands/people.d.ts +22 -0
  7. package/dist/commands/people.js +187 -0
  8. package/dist/commands/secrets.js +25 -2
  9. package/dist/index.js +6 -2
  10. package/dist/run/hq-plugin.js +9 -2
  11. package/dist/sentry-dsn.generated.d.ts +1 -1
  12. package/dist/sentry-dsn.generated.js +1 -1
  13. package/dist/utils/feedback-diagnostics.d.ts +7 -0
  14. package/dist/utils/feedback-diagnostics.js +4 -2
  15. package/dist/utils/feedback-screenshots.d.ts +23 -0
  16. package/dist/utils/feedback-screenshots.js +98 -0
  17. package/dist/utils/feedback-versions.d.ts +34 -0
  18. package/dist/utils/feedback-versions.js +50 -0
  19. package/dist/utils/people.d.ts +99 -0
  20. package/dist/utils/people.js +168 -0
  21. package/package.json +1 -1
  22. package/src/commands/feedback.test.ts +44 -0
  23. package/src/commands/feedback.ts +46 -13
  24. package/src/commands/files-delete.test.ts +132 -0
  25. package/src/commands/files.ts +42 -1
  26. package/src/commands/people.test.ts +426 -0
  27. package/src/commands/people.ts +240 -0
  28. package/src/commands/secrets.test.ts +80 -0
  29. package/src/commands/secrets.ts +35 -0
  30. package/src/index.ts +4 -0
  31. package/src/run/hq-plugin.test.ts +39 -0
  32. package/src/run/hq-plugin.ts +7 -0
  33. package/src/utils/feedback-diagnostics.test.ts +11 -0
  34. package/src/utils/feedback-diagnostics.ts +8 -0
  35. package/src/utils/feedback-screenshots.test.ts +134 -0
  36. package/src/utils/feedback-screenshots.ts +124 -0
  37. package/src/utils/feedback-versions.test.ts +98 -0
  38. package/src/utils/feedback-versions.ts +68 -0
  39. package/src/utils/people.ts +222 -0
@@ -38,6 +38,7 @@ import { Command } from "commander";
38
38
  import {
39
39
  registerFilesCommand,
40
40
  runFilesDelete,
41
+ stripRedundantCompanyScope,
41
42
  FilesDeleteHttpError,
42
43
  formatFilesDeleteError,
43
44
  type FilesDeleteResponse,
@@ -242,6 +243,137 @@ describe("hq files delete — root/empty prefix rejected client-side", () => {
242
243
  }
243
244
  });
244
245
 
246
+ // HQ-8F: a caller who pastes an HQ *local* tree path (`companies/<slug>/…`)
247
+ // over-prefixes the bucket-relative vault key; the server rejects it with a 400
248
+ // (INVALID_PREFIX_COMPANIES_SCOPED), the source of the recurring warning. The
249
+ // CLI now strips the redundant scope BEFORE sending, so the local-looking path
250
+ // is gracefully normalized to bucket-relative.
251
+ describe("stripRedundantCompanyScope (HQ-8F, pure)", () => {
252
+ it("strips a leading companies/<slug>/ to the bucket-relative remainder", () => {
253
+ expect(stripRedundantCompanyScope("companies/acme/projects/foo")).toEqual({
254
+ prefix: "projects/foo",
255
+ strippedSlug: "acme",
256
+ });
257
+ expect(stripRedundantCompanyScope("companies/acme/projects/foo/*")).toEqual({
258
+ prefix: "projects/foo/*",
259
+ strippedSlug: "acme",
260
+ });
261
+ });
262
+
263
+ it("returns an EMPTY remainder for the company-root spellings (→ root-reject)", () => {
264
+ expect(stripRedundantCompanyScope("companies/acme")).toEqual({
265
+ prefix: "",
266
+ strippedSlug: "acme",
267
+ });
268
+ expect(stripRedundantCompanyScope("companies/acme/")).toEqual({
269
+ prefix: "",
270
+ strippedSlug: "acme",
271
+ });
272
+ });
273
+
274
+ it("leaves an already bucket-relative prefix untouched (null)", () => {
275
+ expect(stripRedundantCompanyScope("projects/foo/*")).toBeNull();
276
+ expect(stripRedundantCompanyScope("reports/q3/")).toBeNull();
277
+ // A non-scope path that merely starts with the word "companies" is NOT a
278
+ // scope prefix and must be left alone.
279
+ expect(stripRedundantCompanyScope("companiesreport/x")).toBeNull();
280
+ });
281
+ });
282
+
283
+ describe("hq files delete — HQ-8F company-scope normalization", () => {
284
+ it("strips companies/<slug>/ and sends the BUCKET-RELATIVE prefix (no 400)", async () => {
285
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
286
+ fetchSpy.mockResolvedValueOnce(
287
+ deleteResponse({ dryRun: true, matched: 1, keys: ["projects/foo/a.md"] }),
288
+ );
289
+ fetchSpy.mockResolvedValueOnce(
290
+ deleteResponse({ matched: 1, deleted: 1, tombstoned: 1, keys: ["projects/foo/a.md"] }),
291
+ );
292
+
293
+ await runFilesDelete(
294
+ {
295
+ // Trailing slash → also exercises the `/` → `/*` glob normalization.
296
+ prefix: "companies/acme/projects/foo/",
297
+ dryRun: false,
298
+ yes: true,
299
+ companySlug: undefined,
300
+ },
301
+ { confirm: async () => true },
302
+ );
303
+
304
+ const bodies = deleteCallBodies();
305
+ // Both the preview and the delete carry the stripped, bucket-relative prefix.
306
+ expect(bodies.map((b) => b.prefix)).toEqual(["projects/foo/*", "projects/foo/*"]);
307
+ expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
308
+ });
309
+
310
+ it("a bare companies/<slug> strips to empty → root-reject, no network call", async () => {
311
+ const program = buildProgram();
312
+ await expect(
313
+ program.parseAsync(["files", "delete", "companies/acme", "--yes"], {
314
+ from: "user",
315
+ }),
316
+ ).rejects.toThrow(/__EXIT__:1/);
317
+ expect(fetchSpy).not.toHaveBeenCalled();
318
+ expect(printedErr()).toContain("Refusing to delete the vault root");
319
+ });
320
+ });
321
+
322
+ // HQ-CA: the EXACT-KEY sibling of HQ-8F. When the over-prefixed path has no
323
+ // trailing slash and no wildcard (a literal key like
324
+ // `companies/<slug>/notes/foo.md`), the server takes the exact-key branch where
325
+ // `validateObjectKey` — not `validatePrefix` — rejects it with a 400 ("Invalid
326
+ // key: … do not prefix with 'companies/<slug>/'."). The same client-side strip
327
+ // runs UPSTREAM of the server's exact-vs-glob split, so it normalizes the exact
328
+ // key to bucket-relative and the 400 is never emitted. This locks that path,
329
+ // which #117 only exercised via the trailing-slash (glob) spelling.
330
+ describe("hq files delete — HQ-CA exact-key company-scope normalization", () => {
331
+ it("strips companies/<slug>/ from an EXACT key and sends the bucket-relative key (no glob, no 400)", async () => {
332
+ fetchSpy.mockResolvedValueOnce(membershipResponse());
333
+ fetchSpy.mockResolvedValueOnce(
334
+ deleteResponse({
335
+ dryRun: true,
336
+ mode: "exact",
337
+ prefix: "notes/foo.md",
338
+ matched: 1,
339
+ keys: ["notes/foo.md"],
340
+ }),
341
+ );
342
+ fetchSpy.mockResolvedValueOnce(
343
+ deleteResponse({
344
+ mode: "exact",
345
+ prefix: "notes/foo.md",
346
+ matched: 1,
347
+ deleted: 1,
348
+ tombstoned: 1,
349
+ keys: ["notes/foo.md"],
350
+ }),
351
+ );
352
+
353
+ await runFilesDelete(
354
+ {
355
+ // No trailing slash, no wildcard → server takes the EXACT-key branch.
356
+ prefix: "companies/acme/notes/foo.md",
357
+ dryRun: false,
358
+ yes: true,
359
+ companySlug: undefined,
360
+ },
361
+ { confirm: async () => true },
362
+ );
363
+
364
+ const bodies = deleteCallBodies();
365
+ // Preview + delete both carry the stripped, bucket-relative EXACT key —
366
+ // never the `companies/acme/...` over-prefix that 400s, and never glob-ified
367
+ // into a prefix delete.
368
+ expect(bodies.map((b) => b.prefix)).toEqual(["notes/foo.md", "notes/foo.md"]);
369
+ for (const b of bodies) {
370
+ expect(b.prefix.startsWith("companies/")).toBe(false);
371
+ expect(b.prefix).not.toContain("*");
372
+ }
373
+ expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
374
+ });
375
+ });
376
+
245
377
  describe("hq files delete — server error mapping", () => {
246
378
  it("403 surfaces a clear not-authorized message and exits 1", async () => {
247
379
  fetchSpy.mockResolvedValueOnce(membershipResponse());
@@ -782,17 +782,58 @@ function printKeyPreview(resp: FilesDeleteResponse): void {
782
782
  }
783
783
  }
784
784
 
785
+ /**
786
+ * The vault bucket is already company-scoped, so a delete prefix must be
787
+ * BUCKET-RELATIVE (e.g. `projects/foo/*`). A caller who pastes an HQ *local*
788
+ * tree path (`companies/<slug>/projects/foo`) over-prefixes it; the server then
789
+ * rejects it with INVALID_PREFIX_COMPANIES_SCOPED (HTTP 400), the source of the
790
+ * recurring Sentry warning HQ-8F. Strip a redundant leading `companies/<slug>/`
791
+ * so the local-looking path is normalized to the bucket-relative key the vault
792
+ * actually stores. Returns the stripped slug for a one-line notice, or null when
793
+ * there was nothing to strip. Pure → unit-testable.
794
+ *
795
+ * This runs UPSTREAM of the server's exact-vs-glob branch, so it covers both
796
+ * the glob spelling (`companies/<slug>/projects/foo/*` → `validatePrefix`,
797
+ * HQ-8F) and the EXACT-key spelling (`companies/<slug>/notes/foo.md` →
798
+ * `validateObjectKey`, HQ-CA) with the same normalization.
799
+ */
800
+ export function stripRedundantCompanyScope(
801
+ prefix: string,
802
+ ): { prefix: string; strippedSlug: string } | null {
803
+ const m = /^companies\/([^/]+)(?:\/(.*))?$/.exec(prefix);
804
+ if (!m) return null;
805
+ return { prefix: m[2] ?? "", strippedSlug: m[1] };
806
+ }
807
+
785
808
  export async function runFilesDelete(
786
809
  params: RunFilesDeleteParams,
787
810
  deps: { confirm?: ConfirmFn } = {},
788
811
  ): Promise<void> {
789
812
  const confirm = deps.confirm ?? realConfirm;
790
813
 
814
+ // The vault is already company-scoped — a `companies/<slug>/` prefix is the HQ
815
+ // LOCAL tree layout, not a vault key, and the server 400s it (HQ-8F). Strip it
816
+ // here so a pasted local path is gracefully normalized to bucket-relative
817
+ // BEFORE the dry-run/preview (so the operator still sees the exact keys and
818
+ // confirms the right target). If stripping empties the prefix, the root-reject
819
+ // below catches it with a clear message.
820
+ const scope = stripRedundantCompanyScope(params.prefix);
821
+ if (scope) {
822
+ console.error(
823
+ chalk.yellow(
824
+ `Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
825
+ `is already company-scoped; using bucket-relative ` +
826
+ `'${scope.prefix || "(root)"}'.`,
827
+ ),
828
+ );
829
+ }
830
+ const rawPrefix = scope ? scope.prefix : params.prefix;
831
+
791
832
  // Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
792
833
  // then reject the root/empty prefix CLIENT-side so a typo never reaches the
793
834
  // server as a vault-wide delete. The server enforces this too (defense in
794
835
  // depth), but failing fast here is clearer and avoids a wasted round-trip.
795
- const normalized = normalizeFilePrefix(params.prefix);
836
+ const normalized = normalizeFilePrefix(rawPrefix);
796
837
  if (normalized === "" || normalized === "*" || normalized === "/*") {
797
838
  console.error(
798
839
  chalk.red(
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Unit tests for the `hq people` capability:
3
+ * - utils/people.ts — parse, list, search, resolve (pure + filesystem)
4
+ * - people.ts — active-company resolution + wired list/search/resolve
5
+ */
6
+
7
+ import * as fs from "fs";
8
+ import * as os from "os";
9
+ import * as path from "path";
10
+ import { Command } from "commander";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import {
14
+ assertSafeCompanySlug,
15
+ companyPeopleDir,
16
+ parsePersonMeta,
17
+ listCompanyPeople,
18
+ searchPeople,
19
+ resolveNameToEmail,
20
+ type PersonRecord,
21
+ } from "../utils/people.js";
22
+ import { registerPeopleCommand, resolveCompanySlug } from "./people.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Filesystem fixture helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ let tmpRoot: string;
29
+
30
+ function makePerson(
31
+ company: string,
32
+ slug: string,
33
+ meta: Record<string, unknown> | string,
34
+ ): void {
35
+ const dir = path.join(tmpRoot, "companies", company, "people", slug);
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ const body =
38
+ typeof meta === "string"
39
+ ? meta
40
+ : Object.entries(meta)
41
+ .map(([k, v]) =>
42
+ Array.isArray(v)
43
+ ? `${k}: [${v.join(", ")}]`
44
+ : `${k}: ${JSON.stringify(v)}`,
45
+ )
46
+ .join("\n");
47
+ fs.writeFileSync(path.join(dir, "meta.yaml"), body + "\n");
48
+ }
49
+
50
+ function writeCompaniesManifest(
51
+ companies: Record<string, Record<string, unknown> | null>,
52
+ ): void {
53
+ const dir = path.join(tmpRoot, "companies");
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ const yamlBody =
56
+ "companies:\n" +
57
+ Object.entries(companies)
58
+ .map(([slug, entry]) => {
59
+ if (!entry) return ` ${slug}:`;
60
+ const fields = Object.entries(entry)
61
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
62
+ .join("\n");
63
+ return ` ${slug}:\n${fields}`;
64
+ })
65
+ .join("\n") +
66
+ "\n";
67
+ fs.writeFileSync(path.join(dir, "manifest.yaml"), yamlBody);
68
+ }
69
+
70
+ beforeEach(() => {
71
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-people-"));
72
+ });
73
+
74
+ afterEach(() => {
75
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // assertSafeCompanySlug
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("assertSafeCompanySlug", () => {
84
+ it("accepts ordinary slugs", () => {
85
+ expect(() => assertSafeCompanySlug("indigo")).not.toThrow();
86
+ expect(() => assertSafeCompanySlug("acme-co_1.2")).not.toThrow();
87
+ });
88
+
89
+ it("rejects path-traversal and separators", () => {
90
+ expect(() => assertSafeCompanySlug("..")).toThrow();
91
+ expect(() => assertSafeCompanySlug("a/b")).toThrow();
92
+ expect(() => assertSafeCompanySlug("../etc")).toThrow();
93
+ expect(() => assertSafeCompanySlug("")).toThrow();
94
+ });
95
+
96
+ it("rejects reserved slugs", () => {
97
+ expect(() => assertSafeCompanySlug("personal")).toThrow(/reserved/);
98
+ });
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // parsePersonMeta
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("parsePersonMeta", () => {
106
+ it("parses a full record and trims fields", () => {
107
+ const rec = parsePersonMeta(
108
+ "name: Jane Smith\nemail: jane@example.com\ntype: internal\nrole: Head of Eng\ntags: [eng, lead]\n",
109
+ "jane-smith",
110
+ "/x/meta.yaml",
111
+ );
112
+ expect(rec).toEqual({
113
+ slug: "jane-smith",
114
+ name: "Jane Smith",
115
+ email: "jane@example.com",
116
+ type: "internal",
117
+ role: "Head of Eng",
118
+ tags: ["eng", "lead"],
119
+ source: "/x/meta.yaml",
120
+ });
121
+ });
122
+
123
+ it("returns null when name is missing or blank", () => {
124
+ expect(parsePersonMeta("email: a@b.com\n", "x", "/x")).toBeNull();
125
+ expect(parsePersonMeta("name: ' '\n", "x", "/x")).toBeNull();
126
+ });
127
+
128
+ it("returns null for empty / non-object docs", () => {
129
+ expect(parsePersonMeta("", "x", "/x")).toBeNull();
130
+ expect(parsePersonMeta("- a\n- b\n", "x", "/x")).toBeNull();
131
+ });
132
+
133
+ it("throws on unparseable yaml", () => {
134
+ expect(() => parsePersonMeta("name: [unterminated\n", "x", "/x")).toThrow(
135
+ /Failed to parse/,
136
+ );
137
+ });
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // listCompanyPeople
142
+ // ---------------------------------------------------------------------------
143
+
144
+ describe("listCompanyPeople", () => {
145
+ it("returns [] when the company has no people directory", () => {
146
+ writeCompaniesManifest({ acme: { status: "active" } });
147
+ expect(listCompanyPeople(tmpRoot, "acme")).toEqual([]);
148
+ });
149
+
150
+ it("reads people, skips _scaffolding, nameless, and meta-less folders, sorted by name", () => {
151
+ makePerson("acme", "zed", { name: "Zed Zebra", email: "zed@acme.com" });
152
+ makePerson("acme", "ann", { name: "Ann Apple", email: "ann@acme.com" });
153
+ makePerson("acme", "_example", { name: "Template Person" });
154
+ makePerson("acme", "nameless", { email: "noone@acme.com" });
155
+ // folder with no meta.yaml
156
+ fs.mkdirSync(path.join(tmpRoot, "companies", "acme", "people", "empty"), {
157
+ recursive: true,
158
+ });
159
+
160
+ const people = listCompanyPeople(tmpRoot, "acme");
161
+ expect(people.map((p) => p.name)).toEqual(["Ann Apple", "Zed Zebra"]);
162
+ });
163
+
164
+ it("never reads outside the named company (tenancy)", () => {
165
+ makePerson("acme", "ann", { name: "Ann Apple", email: "ann@acme.com" });
166
+ makePerson("other", "bob", { name: "Bob Other", email: "bob@other.com" });
167
+ const people = listCompanyPeople(tmpRoot, "acme");
168
+ expect(people.map((p) => p.name)).toEqual(["Ann Apple"]);
169
+ });
170
+
171
+ it("rejects an unsafe company slug", () => {
172
+ expect(() => listCompanyPeople(tmpRoot, "../other")).toThrow();
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // searchPeople
178
+ // ---------------------------------------------------------------------------
179
+
180
+ const SAMPLE: PersonRecord[] = [
181
+ { slug: "jane-smith", name: "Jane Smith", email: "jane@example.com", source: "a" },
182
+ { slug: "john-doe", name: "John Doe", email: "john@acme.com", source: "b" },
183
+ { slug: "janet-lee", name: "Janet Lee", source: "c" },
184
+ ];
185
+
186
+ describe("searchPeople", () => {
187
+ it("matches on name (case-insensitive)", () => {
188
+ // "jane" is a substring of both "Jane Smith" and "Janet Lee".
189
+ expect(searchPeople(SAMPLE, "jane").map((p) => p.slug)).toEqual([
190
+ "jane-smith",
191
+ "janet-lee",
192
+ ]);
193
+ expect(searchPeople(SAMPLE, "smith").map((p) => p.slug)).toEqual([
194
+ "jane-smith",
195
+ ]);
196
+ expect(searchPeople(SAMPLE, "JAN").map((p) => p.slug)).toEqual([
197
+ "jane-smith",
198
+ "janet-lee",
199
+ ]);
200
+ });
201
+
202
+ it("matches on email", () => {
203
+ expect(searchPeople(SAMPLE, "acme.com").map((p) => p.slug)).toEqual([
204
+ "john-doe",
205
+ ]);
206
+ });
207
+
208
+ it("matches on slug", () => {
209
+ expect(searchPeople(SAMPLE, "janet-lee").map((p) => p.slug)).toEqual([
210
+ "janet-lee",
211
+ ]);
212
+ });
213
+
214
+ it("returns nothing for an empty/whitespace keyword", () => {
215
+ expect(searchPeople(SAMPLE, "")).toEqual([]);
216
+ expect(searchPeople(SAMPLE, " ")).toEqual([]);
217
+ });
218
+
219
+ it("returns nothing when no one matches", () => {
220
+ expect(searchPeople(SAMPLE, "zzz")).toEqual([]);
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // resolveNameToEmail
226
+ // ---------------------------------------------------------------------------
227
+
228
+ describe("resolveNameToEmail", () => {
229
+ it("resolves an exact name to its email", () => {
230
+ expect(resolveNameToEmail(SAMPLE, "Jane Smith")).toEqual({
231
+ status: "found",
232
+ email: "jane@example.com",
233
+ person: SAMPLE[0],
234
+ });
235
+ });
236
+
237
+ it("is case-insensitive on exact name", () => {
238
+ const r = resolveNameToEmail(SAMPLE, "jane smith");
239
+ expect(r.status).toBe("found");
240
+ });
241
+
242
+ it("prefers an exact name over looser substring matches", () => {
243
+ const people: PersonRecord[] = [
244
+ { slug: "jan", name: "Jan", email: "jan@x.com", source: "a" },
245
+ { slug: "jana", name: "Janabc", email: "jana@x.com", source: "b" },
246
+ ];
247
+ expect(resolveNameToEmail(people, "Jan")).toMatchObject({
248
+ status: "found",
249
+ email: "jan@x.com",
250
+ });
251
+ });
252
+
253
+ it("resolves via slug when name doesn't match exactly", () => {
254
+ expect(resolveNameToEmail(SAMPLE, "john-doe")).toMatchObject({
255
+ status: "found",
256
+ email: "john@acme.com",
257
+ });
258
+ });
259
+
260
+ it("reports ambiguity when a substring matches several", () => {
261
+ const r = resolveNameToEmail(SAMPLE, "jan");
262
+ expect(r.status).toBe("ambiguous");
263
+ if (r.status === "ambiguous") {
264
+ expect(r.matches.map((m) => m.slug)).toEqual(["jane-smith", "janet-lee"]);
265
+ }
266
+ });
267
+
268
+ it("reports no_email when the sole match has no email", () => {
269
+ expect(resolveNameToEmail(SAMPLE, "Janet Lee")).toMatchObject({
270
+ status: "no_email",
271
+ });
272
+ });
273
+
274
+ it("reports not_found when nothing matches", () => {
275
+ expect(resolveNameToEmail(SAMPLE, "Nobody")).toEqual({
276
+ status: "not_found",
277
+ });
278
+ expect(resolveNameToEmail(SAMPLE, " ")).toEqual({ status: "not_found" });
279
+ });
280
+ });
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // resolveCompanySlug
284
+ // ---------------------------------------------------------------------------
285
+
286
+ describe("resolveCompanySlug", () => {
287
+ it("passes through an explicit, valid slug", () => {
288
+ expect(resolveCompanySlug(tmpRoot, "indigo")).toBe("indigo");
289
+ });
290
+
291
+ it("rejects an explicit unsafe slug", () => {
292
+ expect(() => resolveCompanySlug(tmpRoot, "../escape")).toThrow();
293
+ });
294
+
295
+ it("defaults to the sole company in the manifest", () => {
296
+ writeCompaniesManifest({ indigo: { status: "active" } });
297
+ expect(resolveCompanySlug(tmpRoot, undefined)).toBe("indigo");
298
+ });
299
+
300
+ it("ignores archived companies when defaulting", () => {
301
+ writeCompaniesManifest({
302
+ indigo: { status: "active" },
303
+ old: { status: "archived" },
304
+ });
305
+ expect(resolveCompanySlug(tmpRoot, undefined)).toBe("indigo");
306
+ });
307
+
308
+ it("requires --company when several companies exist", () => {
309
+ writeCompaniesManifest({ a: { status: "active" }, b: { status: "active" } });
310
+ expect(() => resolveCompanySlug(tmpRoot, undefined)).toThrow(
311
+ /Multiple companies/,
312
+ );
313
+ });
314
+
315
+ it("errors clearly when the manifest is absent", () => {
316
+ expect(() => resolveCompanySlug(tmpRoot, undefined)).toThrow(
317
+ /manifest\.yaml not found/,
318
+ );
319
+ });
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // companyPeopleDir
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe("companyPeopleDir", () => {
327
+ it("builds the scoped path", () => {
328
+ expect(companyPeopleDir("/hq", "indigo")).toBe(
329
+ path.join("/hq", "companies", "indigo", "people"),
330
+ );
331
+ });
332
+ });
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Wired CLI surface (list / search / resolve)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ function buildProgram(): Command {
339
+ const program = new Command();
340
+ program.exitOverride(); // throw instead of process.exit on commander errors
341
+ registerPeopleCommand(program);
342
+ return program;
343
+ }
344
+
345
+ async function run(args: string[]): Promise<void> {
346
+ await buildProgram().parseAsync(["node", "hq", ...args]);
347
+ }
348
+
349
+ describe("hq people (wired)", () => {
350
+ let logSpy: ReturnType<typeof vi.spyOn>;
351
+ let errSpy: ReturnType<typeof vi.spyOn>;
352
+ let exitSpy: ReturnType<typeof vi.spyOn>;
353
+
354
+ beforeEach(() => {
355
+ writeCompaniesManifest({ acme: { status: "active" } });
356
+ makePerson("acme", "jane-smith", {
357
+ name: "Jane Smith",
358
+ email: "jane@acme.com",
359
+ role: "CEO",
360
+ type: "internal",
361
+ });
362
+ makePerson("acme", "john-doe", {
363
+ name: "John Doe",
364
+ email: "john@acme.com",
365
+ type: "internal",
366
+ });
367
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
368
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
369
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((): never => {
370
+ throw new Error("__exit__");
371
+ }) as never);
372
+ });
373
+
374
+ it("list --json emits both people", async () => {
375
+ await run(["people", "list", "--company", "acme", "--hq-root", tmpRoot, "--json"]);
376
+ const out = JSON.parse(logSpy.mock.calls[0][0] as string);
377
+ expect(out.map((p: PersonRecord) => p.name).sort()).toEqual([
378
+ "Jane Smith",
379
+ "John Doe",
380
+ ]);
381
+ });
382
+
383
+ it("search narrows to the matching person", async () => {
384
+ await run([
385
+ "people",
386
+ "search",
387
+ "jane",
388
+ "--company",
389
+ "acme",
390
+ "--hq-root",
391
+ tmpRoot,
392
+ "--json",
393
+ ]);
394
+ const out = JSON.parse(logSpy.mock.calls[0][0] as string);
395
+ expect(out).toHaveLength(1);
396
+ expect(out[0].email).toBe("jane@acme.com");
397
+ });
398
+
399
+ it("resolve prints the bare email on stdout", async () => {
400
+ await run([
401
+ "people",
402
+ "resolve",
403
+ "Jane Smith",
404
+ "--company",
405
+ "acme",
406
+ "--hq-root",
407
+ tmpRoot,
408
+ ]);
409
+ expect(logSpy).toHaveBeenCalledWith("jane@acme.com");
410
+ });
411
+
412
+ it("resolve exits non-zero when not found", async () => {
413
+ await expect(
414
+ run([
415
+ "people",
416
+ "resolve",
417
+ "Nobody",
418
+ "--company",
419
+ "acme",
420
+ "--hq-root",
421
+ tmpRoot,
422
+ ]),
423
+ ).rejects.toThrow("__exit__");
424
+ expect(exitSpy).toHaveBeenCalledWith(1);
425
+ });
426
+ });