@indigoai-us/hq-cli 5.47.15 → 5.47.16
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/dist/commands/people.d.ts +22 -0
- package/dist/commands/people.js +187 -0
- package/dist/index.js +6 -2
- package/dist/utils/people.d.ts +99 -0
- package/dist/utils/people.js +168 -0
- package/package.json +1 -1
- package/src/commands/people.test.ts +426 -0
- package/src/commands/people.ts +240 -0
- package/src/index.ts +4 -0
- package/src/utils/people.ts +222 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq people` — read a company's people (membership) records from the local HQ
|
|
3
|
+
* tree and search/resolve over them.
|
|
4
|
+
*
|
|
5
|
+
* hq people list [--company <slug>] [--json]
|
|
6
|
+
* hq people search <keyword> [--company <slug>] [--json]
|
|
7
|
+
* hq people resolve <name> [--company <slug>] [--json]
|
|
8
|
+
*
|
|
9
|
+
* Source of truth is `companies/<company>/people/<person>/meta.yaml`. Every
|
|
10
|
+
* subcommand operates on exactly ONE company (the active company, or the one
|
|
11
|
+
* named by `--company`); nothing reads across company boundaries.
|
|
12
|
+
*/
|
|
13
|
+
import { Command } from "commander";
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the single company to operate on. Explicit `--company` always wins
|
|
16
|
+
* (after a path-safety check). Otherwise the active company is inferred from
|
|
17
|
+
* `companies/manifest.yaml`: if exactly one company exists it's used; if several
|
|
18
|
+
* do, the caller must disambiguate with `--company`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveCompanySlug(hqRoot: string, explicit: string | undefined): string;
|
|
21
|
+
export declare function registerPeopleCommand(program: Command): void;
|
|
22
|
+
//# sourceMappingURL=people.d.ts.map
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq people` — read a company's people (membership) records from the local HQ
|
|
3
|
+
* tree and search/resolve over them.
|
|
4
|
+
*
|
|
5
|
+
* hq people list [--company <slug>] [--json]
|
|
6
|
+
* hq people search <keyword> [--company <slug>] [--json]
|
|
7
|
+
* hq people resolve <name> [--company <slug>] [--json]
|
|
8
|
+
*
|
|
9
|
+
* Source of truth is `companies/<company>/people/<person>/meta.yaml`. Every
|
|
10
|
+
* subcommand operates on exactly ONE company (the active company, or the one
|
|
11
|
+
* named by `--company`); nothing reads across company boundaries.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
!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]="076b7f64-6626-5733-8992-ab9361a3e31d")}catch(e){}}();
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import { Option } from "commander";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import * as yaml from "js-yaml";
|
|
19
|
+
import { findHqRoot } from "../utils/manifest.js";
|
|
20
|
+
import { manifestPath } from "./cloud-provision.js";
|
|
21
|
+
import { assertSafeCompanySlug, listCompanyPeople, searchPeople, resolveNameToEmail, companyPeopleDir, } from "../utils/people.js";
|
|
22
|
+
/** Companies that still exist (anything not explicitly `status: archived`). */
|
|
23
|
+
function activeCompanySlugs(manifest) {
|
|
24
|
+
const companies = manifest.companies ?? {};
|
|
25
|
+
return Object.entries(companies)
|
|
26
|
+
.filter(([, entry]) => (entry?.status ?? "active") !== "archived")
|
|
27
|
+
.map(([slug]) => slug)
|
|
28
|
+
.sort();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the single company to operate on. Explicit `--company` always wins
|
|
32
|
+
* (after a path-safety check). Otherwise the active company is inferred from
|
|
33
|
+
* `companies/manifest.yaml`: if exactly one company exists it's used; if several
|
|
34
|
+
* do, the caller must disambiguate with `--company`.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveCompanySlug(hqRoot, explicit) {
|
|
37
|
+
if (explicit) {
|
|
38
|
+
assertSafeCompanySlug(explicit);
|
|
39
|
+
return explicit;
|
|
40
|
+
}
|
|
41
|
+
const mPath = manifestPath(hqRoot);
|
|
42
|
+
if (!fs.existsSync(mPath)) {
|
|
43
|
+
throw new Error("Could not determine the active company — companies/manifest.yaml not found. " +
|
|
44
|
+
"Re-run with --company <slug>.");
|
|
45
|
+
}
|
|
46
|
+
let manifest;
|
|
47
|
+
try {
|
|
48
|
+
manifest = yaml.load(fs.readFileSync(mPath, "utf-8"));
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new Error(`companies/manifest.yaml is malformed: ${err instanceof Error ? err.message : String(err)}`);
|
|
52
|
+
}
|
|
53
|
+
const slugs = activeCompanySlugs(manifest ?? {});
|
|
54
|
+
if (slugs.length === 1)
|
|
55
|
+
return slugs[0];
|
|
56
|
+
if (slugs.length === 0) {
|
|
57
|
+
throw new Error("No companies found in companies/manifest.yaml — re-run with --company <slug>.");
|
|
58
|
+
}
|
|
59
|
+
throw new Error("Multiple companies found — re-run with --company <slug> to pick one:\n" +
|
|
60
|
+
slugs.map((s) => ` --company ${s}`).join("\n"));
|
|
61
|
+
}
|
|
62
|
+
/** Resolve the HQ root: explicit override (tests) → cwd walk-up. */
|
|
63
|
+
function resolveHqRoot(opts) {
|
|
64
|
+
return opts.hqRoot ?? findHqRoot();
|
|
65
|
+
}
|
|
66
|
+
function printPeopleTable(people) {
|
|
67
|
+
const nameW = Math.max(4, ...people.map((p) => p.name.length));
|
|
68
|
+
const emailW = Math.max(5, ...people.map((p) => (p.email ?? "—").length));
|
|
69
|
+
const roleW = Math.max(4, ...people.map((p) => (p.role ?? "—").length));
|
|
70
|
+
console.log(chalk.bold([
|
|
71
|
+
"NAME".padEnd(nameW),
|
|
72
|
+
"EMAIL".padEnd(emailW),
|
|
73
|
+
"ROLE".padEnd(roleW),
|
|
74
|
+
"TYPE",
|
|
75
|
+
].join(" ")));
|
|
76
|
+
for (const p of people) {
|
|
77
|
+
console.log([
|
|
78
|
+
p.name.padEnd(nameW),
|
|
79
|
+
(p.email ?? "—").padEnd(emailW),
|
|
80
|
+
(p.role ?? "—").padEnd(roleW),
|
|
81
|
+
p.type ?? "—",
|
|
82
|
+
].join(" "));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function fail(message) {
|
|
86
|
+
console.error(chalk.red(message));
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
export function registerPeopleCommand(program) {
|
|
90
|
+
const people = program
|
|
91
|
+
.command("people")
|
|
92
|
+
.description("List, search, and resolve a company's people (from companies/<co>/people)")
|
|
93
|
+
.option("--company <slug>", "Company slug to scope to (defaults to the active company)")
|
|
94
|
+
// Hidden escape hatch for tests / non-standard layouts — point the reader at
|
|
95
|
+
// an explicit HQ tree root instead of walking up from cwd.
|
|
96
|
+
.addOption(new Option("--hq-root <path>", "Override the HQ tree root (advanced)").hideHelp());
|
|
97
|
+
people
|
|
98
|
+
.command("list")
|
|
99
|
+
.description("List all people recorded for the company")
|
|
100
|
+
.option("--json", "Output JSON instead of a table")
|
|
101
|
+
.action((opts) => {
|
|
102
|
+
try {
|
|
103
|
+
const scope = people.opts();
|
|
104
|
+
const hqRoot = resolveHqRoot(scope);
|
|
105
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
106
|
+
const records = listCompanyPeople(hqRoot, slug);
|
|
107
|
+
if (opts.json) {
|
|
108
|
+
console.log(JSON.stringify(records, null, 2));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (records.length === 0) {
|
|
112
|
+
console.log(chalk.gray(`No people recorded for '${slug}' (looked in ${companyPeopleDir(hqRoot, slug)}).`));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
printPeopleTable(records);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
people
|
|
122
|
+
.command("search <keyword>")
|
|
123
|
+
.description("Keyword search over people names and emails")
|
|
124
|
+
.option("--json", "Output JSON instead of a table")
|
|
125
|
+
.action((keyword, opts) => {
|
|
126
|
+
try {
|
|
127
|
+
const scope = people.opts();
|
|
128
|
+
const hqRoot = resolveHqRoot(scope);
|
|
129
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
130
|
+
const matches = searchPeople(listCompanyPeople(hqRoot, slug), keyword);
|
|
131
|
+
if (opts.json) {
|
|
132
|
+
console.log(JSON.stringify(matches, null, 2));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (matches.length === 0) {
|
|
136
|
+
console.log(chalk.gray(`No people in '${slug}' match "${keyword}".`));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
printPeopleTable(matches);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
people
|
|
146
|
+
.command("resolve <name>")
|
|
147
|
+
.description("Resolve a person name to their email address")
|
|
148
|
+
.option("--json", "Output JSON instead of plain text")
|
|
149
|
+
.action((name, opts) => {
|
|
150
|
+
try {
|
|
151
|
+
const scope = people.opts();
|
|
152
|
+
const hqRoot = resolveHqRoot(scope);
|
|
153
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
154
|
+
const result = resolveNameToEmail(listCompanyPeople(hqRoot, slug), name);
|
|
155
|
+
if (opts.json) {
|
|
156
|
+
console.log(JSON.stringify(result, null, 2));
|
|
157
|
+
if (result.status === "found")
|
|
158
|
+
return;
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
switch (result.status) {
|
|
162
|
+
case "found":
|
|
163
|
+
// Bare email on stdout so callers can capture it directly.
|
|
164
|
+
console.log(result.email);
|
|
165
|
+
return;
|
|
166
|
+
case "no_email":
|
|
167
|
+
fail(`Found '${result.person.name}' in '${slug}' but no email is recorded for them.`);
|
|
168
|
+
break;
|
|
169
|
+
case "ambiguous":
|
|
170
|
+
console.error(chalk.yellow(`"${name}" matches ${result.matches.length} people in '${slug}' — be more specific:`));
|
|
171
|
+
for (const m of result.matches) {
|
|
172
|
+
console.error(` ${m.name}${m.email ? ` <${m.email}>` : ""}`);
|
|
173
|
+
}
|
|
174
|
+
process.exit(1);
|
|
175
|
+
break;
|
|
176
|
+
case "not_found":
|
|
177
|
+
fail(`No person matching "${name}" found in '${slug}'.`);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=people.js.map
|
|
187
|
+
//# debugId=076b7f64-6626-5733-8992-ab9361a3e31d
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* HQ CLI - Module management, package management, and cloud sync for HQ
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
!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]="
|
|
6
|
+
!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]="aeb143c7-7e19-5999-b6cb-790fc79034cc")}catch(e){}}();
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { initSentry, Sentry } from "./sentry.js";
|
|
9
9
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -36,6 +36,7 @@ import { registerGroupGrantsCommand } from "./commands/group-grants.js";
|
|
|
36
36
|
import { registerFilesCommand } from "./commands/files.js";
|
|
37
37
|
import { registerFilesBrowseCommands } from "./commands/files-browse.js";
|
|
38
38
|
import { registerMembersCommand } from "./commands/members.js";
|
|
39
|
+
import { registerPeopleCommand } from "./commands/people.js";
|
|
39
40
|
import { registerDmCommand } from "./commands/dm.js";
|
|
40
41
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
41
42
|
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
@@ -134,6 +135,9 @@ const filesCmd = registerFilesCommand(program);
|
|
|
134
135
|
registerFilesBrowseCommands(filesCmd);
|
|
135
136
|
// Membership management (subcommand group — hq members invite|list|revoke)
|
|
136
137
|
registerMembersCommand(program);
|
|
138
|
+
// People directory (subcommand group — hq people list|search|resolve), reading
|
|
139
|
+
// the local companies/<co>/people store scoped to one company.
|
|
140
|
+
registerPeopleCommand(program);
|
|
137
141
|
registerDmCommand(program);
|
|
138
142
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
139
143
|
registerOnboardCommand(program);
|
|
@@ -180,4 +184,4 @@ registerRescueCommand(program);
|
|
|
180
184
|
}
|
|
181
185
|
})();
|
|
182
186
|
//# sourceMappingURL=index.js.map
|
|
183
|
-
//# debugId=
|
|
187
|
+
//# debugId=aeb143c7-7e19-5999-b6cb-790fc79034cc
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company people (membership) directory reader + search.
|
|
3
|
+
*
|
|
4
|
+
* The local source of truth for "who belongs to a company" is the per-company
|
|
5
|
+
* people store on disk:
|
|
6
|
+
*
|
|
7
|
+
* companies/<company-slug>/people/<person-slug>/meta.yaml
|
|
8
|
+
*
|
|
9
|
+
* Each `meta.yaml` carries at least `name` and `type` ("internal" | "external")
|
|
10
|
+
* plus optional `email`, `role`, `organization`, `tags`, etc. (see
|
|
11
|
+
* `companies/_template/people/_example/meta.yaml` for the canonical schema).
|
|
12
|
+
*
|
|
13
|
+
* Everything here is scoped to ONE company directory. Callers resolve a single
|
|
14
|
+
* company slug up front; nothing in this module ever enumerates or reads across
|
|
15
|
+
* company boundaries — HQ tenancy rules forbid cross-company member lookups.
|
|
16
|
+
*/
|
|
17
|
+
/** A single person/member record parsed from a `people/<slug>/meta.yaml`. */
|
|
18
|
+
export interface PersonRecord {
|
|
19
|
+
/** Folder slug under `companies/<company>/people/<slug>/`. */
|
|
20
|
+
slug: string;
|
|
21
|
+
/** Display name (required field in `meta.yaml`). */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Contact email, when recorded. */
|
|
24
|
+
email?: string;
|
|
25
|
+
/** "internal" (team member) | "external" (client, vendor, …). */
|
|
26
|
+
type?: string;
|
|
27
|
+
/** Role/title inside (or relative to) the company. */
|
|
28
|
+
role?: string;
|
|
29
|
+
/** External-only: the person's own organization. */
|
|
30
|
+
organization?: string;
|
|
31
|
+
/** Freeform tags for filtering. */
|
|
32
|
+
tags?: string[];
|
|
33
|
+
/** Absolute path to the `meta.yaml` this record was parsed from. */
|
|
34
|
+
source: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function assertSafeCompanySlug(slug: string): void;
|
|
37
|
+
/** Absolute path to a company's `people/` directory. */
|
|
38
|
+
export declare function companyPeopleDir(hqRoot: string, companySlug: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Parse a single `meta.yaml` body into a `PersonRecord`. Returns `null` when the
|
|
41
|
+
* file is empty, unparseable, or has no usable `name` — a person record without
|
|
42
|
+
* a name can't be listed, searched, or resolved, so it's skipped rather than
|
|
43
|
+
* surfaced as a half-row. Exported for unit testing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parsePersonMeta(raw: string, slug: string, source: string): PersonRecord | null;
|
|
46
|
+
/**
|
|
47
|
+
* List every person recorded for ONE company. Reads
|
|
48
|
+
* `companies/<companySlug>/people/<personSlug>/meta.yaml` for each person
|
|
49
|
+
* folder.
|
|
50
|
+
*
|
|
51
|
+
* - Folders whose name starts with `_` are skipped (e.g. the `_example`
|
|
52
|
+
* template that ships in `companies/_template/people/`).
|
|
53
|
+
* - Folders without a `meta.yaml`, or whose `meta.yaml` has no `name`, are
|
|
54
|
+
* skipped silently — they aren't members yet.
|
|
55
|
+
* - Returns `[]` when the company has no `people/` directory at all.
|
|
56
|
+
*
|
|
57
|
+
* Results are sorted by name (case-insensitive) for stable output.
|
|
58
|
+
*/
|
|
59
|
+
export declare function listCompanyPeople(hqRoot: string, companySlug: string): PersonRecord[];
|
|
60
|
+
/**
|
|
61
|
+
* Case-insensitive keyword search over a person's NAME and EMAIL (plus the
|
|
62
|
+
* folder slug, which is a normalized alias of the name). Pure — operates on an
|
|
63
|
+
* already-loaded list so it's trivially testable and reusable.
|
|
64
|
+
*
|
|
65
|
+
* An empty/whitespace keyword matches nothing (callers should treat that as a
|
|
66
|
+
* usage error rather than "return everyone").
|
|
67
|
+
*/
|
|
68
|
+
export declare function searchPeople(people: PersonRecord[], keyword: string): PersonRecord[];
|
|
69
|
+
/** Outcome of resolving a name to an email address. */
|
|
70
|
+
export type ResolveResult = {
|
|
71
|
+
status: "found";
|
|
72
|
+
email: string;
|
|
73
|
+
person: PersonRecord;
|
|
74
|
+
} | {
|
|
75
|
+
status: "no_email";
|
|
76
|
+
person: PersonRecord;
|
|
77
|
+
} | {
|
|
78
|
+
status: "ambiguous";
|
|
79
|
+
matches: PersonRecord[];
|
|
80
|
+
} | {
|
|
81
|
+
status: "not_found";
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a person NAME to their email, built on top of {@link searchPeople}.
|
|
85
|
+
*
|
|
86
|
+
* Match precedence (narrowest first) so a precise query isn't drowned out by
|
|
87
|
+
* looser substring hits:
|
|
88
|
+
* 1. exact name match (case-insensitive, trimmed)
|
|
89
|
+
* 2. exact folder-slug match
|
|
90
|
+
* 3. substring search over name/email/slug
|
|
91
|
+
*
|
|
92
|
+
* The first tier that yields any match decides the result:
|
|
93
|
+
* - exactly one match with an email → `found`
|
|
94
|
+
* - exactly one match, no email → `no_email`
|
|
95
|
+
* - more than one match → `ambiguous` (caller disambiguates)
|
|
96
|
+
* - no match in any tier → `not_found`
|
|
97
|
+
*/
|
|
98
|
+
export declare function resolveNameToEmail(people: PersonRecord[], name: string): ResolveResult;
|
|
99
|
+
//# sourceMappingURL=people.d.ts.map
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company people (membership) directory reader + search.
|
|
3
|
+
*
|
|
4
|
+
* The local source of truth for "who belongs to a company" is the per-company
|
|
5
|
+
* people store on disk:
|
|
6
|
+
*
|
|
7
|
+
* companies/<company-slug>/people/<person-slug>/meta.yaml
|
|
8
|
+
*
|
|
9
|
+
* Each `meta.yaml` carries at least `name` and `type` ("internal" | "external")
|
|
10
|
+
* plus optional `email`, `role`, `organization`, `tags`, etc. (see
|
|
11
|
+
* `companies/_template/people/_example/meta.yaml` for the canonical schema).
|
|
12
|
+
*
|
|
13
|
+
* Everything here is scoped to ONE company directory. Callers resolve a single
|
|
14
|
+
* company slug up front; nothing in this module ever enumerates or reads across
|
|
15
|
+
* company boundaries — HQ tenancy rules forbid cross-company member lookups.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
!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]="aac5adc3-c336-5d9a-8a1a-edaf16ef4c11")}catch(e){}}();
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import * as yaml from "js-yaml";
|
|
22
|
+
/**
|
|
23
|
+
* Company slugs map directly onto a filesystem path segment, so we validate
|
|
24
|
+
* them before joining to keep a malicious or fat-fingered `--company` value
|
|
25
|
+
* from escaping the `companies/` tree. Mirrors the slug rules used by
|
|
26
|
+
* cloud-provision.
|
|
27
|
+
*/
|
|
28
|
+
const COMPANY_SLUG_REGEX = /^[A-Za-z0-9._-]+$/;
|
|
29
|
+
const FORBIDDEN_COMPANY_SLUGS = new Set(["personal", ".", ".."]);
|
|
30
|
+
export function assertSafeCompanySlug(slug) {
|
|
31
|
+
if (!slug || !COMPANY_SLUG_REGEX.test(slug)) {
|
|
32
|
+
throw new Error(`Invalid company slug "${slug}" — must match ${COMPANY_SLUG_REGEX.source}`);
|
|
33
|
+
}
|
|
34
|
+
if (FORBIDDEN_COMPANY_SLUGS.has(slug)) {
|
|
35
|
+
throw new Error(`Company slug "${slug}" is reserved and cannot be used here`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Absolute path to a company's `people/` directory. */
|
|
39
|
+
export function companyPeopleDir(hqRoot, companySlug) {
|
|
40
|
+
assertSafeCompanySlug(companySlug);
|
|
41
|
+
return path.join(hqRoot, "companies", companySlug, "people");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse a single `meta.yaml` body into a `PersonRecord`. Returns `null` when the
|
|
45
|
+
* file is empty, unparseable, or has no usable `name` — a person record without
|
|
46
|
+
* a name can't be listed, searched, or resolved, so it's skipped rather than
|
|
47
|
+
* surfaced as a half-row. Exported for unit testing.
|
|
48
|
+
*/
|
|
49
|
+
export function parsePersonMeta(raw, slug, source) {
|
|
50
|
+
let doc;
|
|
51
|
+
try {
|
|
52
|
+
doc = yaml.load(raw);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new Error(`Failed to parse ${source}: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
if (!doc || typeof doc !== "object")
|
|
58
|
+
return null;
|
|
59
|
+
const d = doc;
|
|
60
|
+
const name = typeof d.name === "string" ? d.name.trim() : "";
|
|
61
|
+
if (!name)
|
|
62
|
+
return null;
|
|
63
|
+
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
64
|
+
const tags = Array.isArray(d.tags)
|
|
65
|
+
? d.tags.filter((t) => typeof t === "string")
|
|
66
|
+
: undefined;
|
|
67
|
+
return {
|
|
68
|
+
slug,
|
|
69
|
+
name,
|
|
70
|
+
email: str(d.email),
|
|
71
|
+
type: str(d.type),
|
|
72
|
+
role: str(d.role),
|
|
73
|
+
organization: str(d.organization),
|
|
74
|
+
...(tags && tags.length > 0 ? { tags } : {}),
|
|
75
|
+
source,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* List every person recorded for ONE company. Reads
|
|
80
|
+
* `companies/<companySlug>/people/<personSlug>/meta.yaml` for each person
|
|
81
|
+
* folder.
|
|
82
|
+
*
|
|
83
|
+
* - Folders whose name starts with `_` are skipped (e.g. the `_example`
|
|
84
|
+
* template that ships in `companies/_template/people/`).
|
|
85
|
+
* - Folders without a `meta.yaml`, or whose `meta.yaml` has no `name`, are
|
|
86
|
+
* skipped silently — they aren't members yet.
|
|
87
|
+
* - Returns `[]` when the company has no `people/` directory at all.
|
|
88
|
+
*
|
|
89
|
+
* Results are sorted by name (case-insensitive) for stable output.
|
|
90
|
+
*/
|
|
91
|
+
export function listCompanyPeople(hqRoot, companySlug) {
|
|
92
|
+
const dir = companyPeopleDir(hqRoot, companySlug);
|
|
93
|
+
if (!fs.existsSync(dir))
|
|
94
|
+
return [];
|
|
95
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
96
|
+
const people = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isDirectory())
|
|
99
|
+
continue;
|
|
100
|
+
if (entry.name.startsWith("_"))
|
|
101
|
+
continue; // _example and other scaffolding
|
|
102
|
+
const metaPath = path.join(dir, entry.name, "meta.yaml");
|
|
103
|
+
if (!fs.existsSync(metaPath))
|
|
104
|
+
continue;
|
|
105
|
+
const raw = fs.readFileSync(metaPath, "utf-8");
|
|
106
|
+
const record = parsePersonMeta(raw, entry.name, metaPath);
|
|
107
|
+
if (record)
|
|
108
|
+
people.push(record);
|
|
109
|
+
}
|
|
110
|
+
people.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
111
|
+
return people;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Case-insensitive keyword search over a person's NAME and EMAIL (plus the
|
|
115
|
+
* folder slug, which is a normalized alias of the name). Pure — operates on an
|
|
116
|
+
* already-loaded list so it's trivially testable and reusable.
|
|
117
|
+
*
|
|
118
|
+
* An empty/whitespace keyword matches nothing (callers should treat that as a
|
|
119
|
+
* usage error rather than "return everyone").
|
|
120
|
+
*/
|
|
121
|
+
export function searchPeople(people, keyword) {
|
|
122
|
+
const needle = keyword.trim().toLowerCase();
|
|
123
|
+
if (!needle)
|
|
124
|
+
return [];
|
|
125
|
+
return people.filter((p) => {
|
|
126
|
+
const haystacks = [p.name, p.email, p.slug].filter((v) => typeof v === "string");
|
|
127
|
+
return haystacks.some((h) => h.toLowerCase().includes(needle));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a person NAME to their email, built on top of {@link searchPeople}.
|
|
132
|
+
*
|
|
133
|
+
* Match precedence (narrowest first) so a precise query isn't drowned out by
|
|
134
|
+
* looser substring hits:
|
|
135
|
+
* 1. exact name match (case-insensitive, trimmed)
|
|
136
|
+
* 2. exact folder-slug match
|
|
137
|
+
* 3. substring search over name/email/slug
|
|
138
|
+
*
|
|
139
|
+
* The first tier that yields any match decides the result:
|
|
140
|
+
* - exactly one match with an email → `found`
|
|
141
|
+
* - exactly one match, no email → `no_email`
|
|
142
|
+
* - more than one match → `ambiguous` (caller disambiguates)
|
|
143
|
+
* - no match in any tier → `not_found`
|
|
144
|
+
*/
|
|
145
|
+
export function resolveNameToEmail(people, name) {
|
|
146
|
+
const query = name.trim();
|
|
147
|
+
if (!query)
|
|
148
|
+
return { status: "not_found" };
|
|
149
|
+
const lowered = query.toLowerCase();
|
|
150
|
+
const exactName = people.filter((p) => p.name.toLowerCase() === lowered);
|
|
151
|
+
const exactSlug = people.filter((p) => p.slug.toLowerCase() === lowered);
|
|
152
|
+
const substring = searchPeople(people, query);
|
|
153
|
+
const matches = exactName.length > 0
|
|
154
|
+
? exactName
|
|
155
|
+
: exactSlug.length > 0
|
|
156
|
+
? exactSlug
|
|
157
|
+
: substring;
|
|
158
|
+
if (matches.length === 0)
|
|
159
|
+
return { status: "not_found" };
|
|
160
|
+
if (matches.length > 1)
|
|
161
|
+
return { status: "ambiguous", matches };
|
|
162
|
+
const person = matches[0];
|
|
163
|
+
if (!person.email)
|
|
164
|
+
return { status: "no_email", person };
|
|
165
|
+
return { status: "found", email: person.email, person };
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=people.js.map
|
|
168
|
+
//# debugId=aac5adc3-c336-5d9a-8a1a-edaf16ef4c11
|