@indigoai-us/hq-cli 5.77.14 → 5.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +42 -2
- package/dist/commands/pkg-install.js +39 -2
- package/dist/commands/run.js +6 -2
- package/dist/commands/secrets.js +120 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2256
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs-update-api-key.test.ts +0 -105
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -103
- package/src/commands/pkg-install.ts +0 -221
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -190
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2050
- package/src/commands/secrets.ts +0 -2249
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- package/vitest.e2e.config.ts +0 -7
|
@@ -1,450 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Content-pack contribution helpers -- the SINGLE SOURCE OF TRUTH (US-003) for
|
|
3
|
-
* the `contributes.* -> host` mapping that `hq install` wires via
|
|
4
|
-
* `core/scripts/scan-packages.sh`.
|
|
5
|
-
*
|
|
6
|
-
* `pack-install.ts` only INSTALLS content packs (into `core/packages/<name>/`,
|
|
7
|
-
* tracked by filesystem presence -- there is no registry file). The list /
|
|
8
|
-
* update / uninstall lifecycle in `commands/packs.ts` needs to reason about the
|
|
9
|
-
* SAME mapping so it can report link health and cleanly un-wire a pack without
|
|
10
|
-
* leaving dangling symlinks.
|
|
11
|
-
*
|
|
12
|
-
* Historically that mapping was RESTATED in five places (the scan-packages.sh
|
|
13
|
-
* bash `case`, pack-install.ts:validateManifest's `subpaths` record,
|
|
14
|
-
* `linkFor`'s switch, `contributionLinks`, and the `PackContributeKey` union),
|
|
15
|
-
* drift-guarded only by a substring check. US-003 collapses it to ONE
|
|
16
|
-
* declarative table -- `CONTRIBUTION_TABLE` below -- with a row per key
|
|
17
|
-
* `{ payload, host, wire }`:
|
|
18
|
-
*
|
|
19
|
-
* - `PackContributeKey` is DERIVED from the table keys (see types.ts).
|
|
20
|
-
* - `linkFor` / `contributionLinks` / the `subpaths` validator READ the table.
|
|
21
|
-
* - `core/scripts/scan-packages.sh` reads a generated data block emitted from
|
|
22
|
-
* this same table (regenerate via `scripts/generate-scan-packages-table.mjs`).
|
|
23
|
-
*
|
|
24
|
-
* A parity test (`pack-contributions.test.ts`) asserts FULL key-set +
|
|
25
|
-
* payload-suffix + wire-mode equivalence across every surface, so the copies
|
|
26
|
-
* cannot drift.
|
|
27
|
-
*
|
|
28
|
-
* `wire` discriminates HOW a contribution reaches the host:
|
|
29
|
-
* - `symlink`: a single `ln -s` into a well-known host directory (every
|
|
30
|
-
* contribution type that shipped before MCP).
|
|
31
|
-
* - `merge`: the contribution is MERGED into a shared host config rather than
|
|
32
|
-
* symlinked (the `mcp` row -- registered into the Claude + Codex agent
|
|
33
|
-
* configs). The merge WIRING is delivered in US-004/US-005; US-003 only
|
|
34
|
-
* DECLARES the row as data so the table is the single source and the parity
|
|
35
|
-
* test guards the real invariant. `symlink`-only readers (linkFor,
|
|
36
|
-
* contributionLinks, the symlink validator, scan-packages) must SKIP
|
|
37
|
-
* `merge` rows -- they never produce a symlink.
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import * as fs from 'fs';
|
|
41
|
-
import * as path from 'path';
|
|
42
|
-
import * as yaml from 'js-yaml';
|
|
43
|
-
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
44
|
-
import { CONTRIBUTION_TABLE, payloadFor, type WireMode } from './contribution-table.js';
|
|
45
|
-
import {
|
|
46
|
-
unregisterMcpServers,
|
|
47
|
-
type UnregisterServerResult,
|
|
48
|
-
type SafeWriteEnv,
|
|
49
|
-
type AcquireLockOptions,
|
|
50
|
-
} from '../commands/mcp-registration.js';
|
|
51
|
-
|
|
52
|
-
// NOTE on dependency direction: this module imports from ../commands/mcp-registration.js
|
|
53
|
-
// to un-register `wire:merge` (mcp) servers on uninstall. mcp-registration.ts imports
|
|
54
|
-
// ONLY fs/os/path/smol-toml (it does NOT import from pack-contributions.ts), so there
|
|
55
|
-
// is NO import cycle. If that ever changes, invert via a callback injected by packs.ts.
|
|
56
|
-
|
|
57
|
-
export { CONTRIBUTION_TABLE };
|
|
58
|
-
export type { WireMode };
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Where a contributes key is ROUTED (US-005). Reads the single-source table's
|
|
62
|
-
* `wire` field — `'symlink'` keys go through `linkFor`/`contributionLinks` (a
|
|
63
|
-
* host `ln -s`); `'merge'` keys (e.g. `mcp`) go through the `registerMcpServers`
|
|
64
|
-
* seam in `commands/mcp-registration.ts` and are NEVER symlinked.
|
|
65
|
-
*
|
|
66
|
-
* This is the explicit, code-enforced discriminator behind the invariant that
|
|
67
|
-
* merge keys never reach the symlink path: callers fan out on the return value
|
|
68
|
-
* rather than relying on `linkFor` happening to return `null`.
|
|
69
|
-
*/
|
|
70
|
-
export function routeContribution(key: PackContributeKey): WireMode {
|
|
71
|
-
return CONTRIBUTION_TABLE[key].wire;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The subset of a pack's declared `contributes` keys that are `wire: 'merge'`
|
|
76
|
-
* (e.g. `mcp`) — the keys that MUST be routed to `registerMcpServers` (US-006)
|
|
77
|
-
* and MUST NOT be symlinked. Unknown keys and non-array/empty values are
|
|
78
|
-
* ignored, mirroring `contributionLinks`. Returned in `contributes` iteration
|
|
79
|
-
* order, de-duplicated.
|
|
80
|
-
*/
|
|
81
|
-
export function mergeKeys(
|
|
82
|
-
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
83
|
-
): PackContributeKey[] {
|
|
84
|
-
const out: PackContributeKey[] = [];
|
|
85
|
-
for (const [key, items] of Object.entries(contributes) as [
|
|
86
|
-
PackContributeKey,
|
|
87
|
-
unknown,
|
|
88
|
-
][]) {
|
|
89
|
-
if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
|
|
90
|
-
if (!Array.isArray(items) || items.length === 0) continue;
|
|
91
|
-
if (routeContribution(key) === 'merge' && !out.includes(key)) out.push(key);
|
|
92
|
-
}
|
|
93
|
-
return out;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** A single symlink a pack contributes: dst (host path) -> src (inside pack). */
|
|
97
|
-
export interface WiredLink {
|
|
98
|
-
key: PackContributeKey;
|
|
99
|
-
item: string;
|
|
100
|
-
src: string; // absolute path inside core/packages/<pack>/
|
|
101
|
-
dst: string; // absolute host path the symlink lives at
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export type LinkStatus =
|
|
105
|
-
| 'live' // dst is a symlink -> this pack's src, and src exists
|
|
106
|
-
| 'broken' // dst is a symlink -> this pack's src, but src is gone
|
|
107
|
-
| 'missing' // dst does not exist (declared but never wired / already removed)
|
|
108
|
-
| 'foreign'; // dst exists but points elsewhere or is a real file (collision)
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Map one SYMLINK contributes entry to its source/host paths, READING the
|
|
112
|
-
* single-source `CONTRIBUTION_TABLE` (US-003). The src is the expanded pack
|
|
113
|
-
* payload; the dst is `<host-dir>/<payload-basename>` so per-key suffixes
|
|
114
|
-
* (`.md`, `.sh`, none) carry through.
|
|
115
|
-
*
|
|
116
|
-
* Returns `null` for a `wire: 'merge'` key (e.g. `mcp`): a merge contribution
|
|
117
|
-
* is not a symlink and has no `WiredLink`. Callers that only handle symlinks
|
|
118
|
-
* (contributionLinks/unwirePack) skip those keys.
|
|
119
|
-
*/
|
|
120
|
-
function linkFor(
|
|
121
|
-
hqRoot: string,
|
|
122
|
-
packDir: string,
|
|
123
|
-
key: PackContributeKey,
|
|
124
|
-
item: string,
|
|
125
|
-
): WiredLink | null {
|
|
126
|
-
const row = CONTRIBUTION_TABLE[key];
|
|
127
|
-
if (row.wire !== 'symlink') return null;
|
|
128
|
-
const payloadRel = payloadFor(key, item); // e.g. commands/foo.md
|
|
129
|
-
// dst keeps the payload's basename (so the suffix carries through) under the
|
|
130
|
-
// host directory declared in the table.
|
|
131
|
-
const dstRel = path.join(row.host, path.basename(payloadRel));
|
|
132
|
-
return {
|
|
133
|
-
key,
|
|
134
|
-
item,
|
|
135
|
-
src: path.join(packDir, payloadRel),
|
|
136
|
-
dst: path.join(hqRoot, dstRel),
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Every SYMLINK a pack's `contributes` block declares. Empty subfields and
|
|
142
|
-
* non-array values are ignored, mirroring scan-packages.sh. `wire: 'merge'`
|
|
143
|
-
* keys (e.g. `mcp`) produce no symlink and are skipped here -- their host
|
|
144
|
-
* effect (a config merge) is handled separately (US-004/US-005).
|
|
145
|
-
*/
|
|
146
|
-
export function contributionLinks(
|
|
147
|
-
hqRoot: string,
|
|
148
|
-
packDir: string,
|
|
149
|
-
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
150
|
-
): WiredLink[] {
|
|
151
|
-
const links: WiredLink[] = [];
|
|
152
|
-
for (const [key, items] of Object.entries(contributes) as [
|
|
153
|
-
PackContributeKey,
|
|
154
|
-
unknown,
|
|
155
|
-
][]) {
|
|
156
|
-
if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
|
|
157
|
-
if (!Array.isArray(items)) continue;
|
|
158
|
-
// INVARIANT (US-005): merge keys are NEVER symlinked. They route to
|
|
159
|
-
// registerMcpServers (commands/mcp-registration.ts) instead. Skip them
|
|
160
|
-
// explicitly here -- not just via linkFor returning null -- so the symlink
|
|
161
|
-
// path provably excludes them.
|
|
162
|
-
if (routeContribution(key) === 'merge') continue;
|
|
163
|
-
for (const item of items) {
|
|
164
|
-
if (typeof item !== 'string' || item.length === 0) continue;
|
|
165
|
-
const link = linkFor(hqRoot, packDir, key, item);
|
|
166
|
-
if (link) links.push(link); // null for merge keys -- skip
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return links;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Classify a host path against the link that should own it. */
|
|
173
|
-
export function linkStatus(link: WiredLink): LinkStatus {
|
|
174
|
-
let st: fs.Stats | undefined;
|
|
175
|
-
try {
|
|
176
|
-
st = fs.lstatSync(link.dst);
|
|
177
|
-
} catch {
|
|
178
|
-
return 'missing';
|
|
179
|
-
}
|
|
180
|
-
if (!st.isSymbolicLink()) {
|
|
181
|
-
return 'foreign'; // a real file/dir occupies the slot -- not ours
|
|
182
|
-
}
|
|
183
|
-
let target: string;
|
|
184
|
-
try {
|
|
185
|
-
target = fs.readlinkSync(link.dst);
|
|
186
|
-
} catch {
|
|
187
|
-
return 'foreign';
|
|
188
|
-
}
|
|
189
|
-
// scan-packages.sh writes the symlink target as the absolute `src` path, so a
|
|
190
|
-
// direct compare is correct. Resolve both to be robust to trailing slashes.
|
|
191
|
-
const resolvedTarget = path.resolve(path.dirname(link.dst), target);
|
|
192
|
-
if (path.resolve(link.src) !== resolvedTarget) {
|
|
193
|
-
return 'foreign'; // points at another pack / somewhere else
|
|
194
|
-
}
|
|
195
|
-
return fs.existsSync(link.src) ? 'live' : 'broken';
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ---------------------------------------------------------------------------
|
|
199
|
-
// Installed-pack discovery
|
|
200
|
-
// ---------------------------------------------------------------------------
|
|
201
|
-
|
|
202
|
-
/** A content pack's manifest plus the install-time stamped source. */
|
|
203
|
-
export interface InstalledPackManifest extends PackManifest {
|
|
204
|
-
source?: string; // stamped by pack-install.ts:stampInstallSource
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export interface InstalledPack {
|
|
208
|
-
name: string; // directory name (== manifest.name when well-formed)
|
|
209
|
-
dir: string; // absolute path to core/packages/<name>/
|
|
210
|
-
manifest: InstalledPackManifest | null; // null when package.yaml is unreadable
|
|
211
|
-
error?: string; // parse/validation error message when manifest is null
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** Absolute path to `<hqRoot>/core/packages`. */
|
|
215
|
-
export function packagesDir(hqRoot: string): string {
|
|
216
|
-
return path.join(hqRoot, 'core', 'packages');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Read and shallowly validate a pack's package.yaml. */
|
|
220
|
-
export function readPackManifest(packDir: string): {
|
|
221
|
-
manifest: InstalledPackManifest | null;
|
|
222
|
-
error?: string;
|
|
223
|
-
} {
|
|
224
|
-
const manifestPath = path.join(packDir, 'package.yaml');
|
|
225
|
-
if (!fs.existsSync(manifestPath)) {
|
|
226
|
-
return { manifest: null, error: 'package.yaml missing' };
|
|
227
|
-
}
|
|
228
|
-
try {
|
|
229
|
-
const parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
|
|
230
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
231
|
-
return { manifest: null, error: 'package.yaml is not a mapping' };
|
|
232
|
-
}
|
|
233
|
-
return { manifest: parsed as InstalledPackManifest };
|
|
234
|
-
} catch (e) {
|
|
235
|
-
return { manifest: null, error: `package.yaml invalid: ${(e as Error).message}` };
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Walk `core/packages/<name>/package.yaml`. Skips the `.archive` dir, the bundled
|
|
241
|
-
* `README.md`, and any non-directory entry. Filesystem presence is the source
|
|
242
|
-
* of truth for installed content packs.
|
|
243
|
-
*/
|
|
244
|
-
export function listInstalledPacks(hqRoot: string): InstalledPack[] {
|
|
245
|
-
const dir = packagesDir(hqRoot);
|
|
246
|
-
if (!fs.existsSync(dir)) return [];
|
|
247
|
-
const out: InstalledPack[] = [];
|
|
248
|
-
for (const name of fs.readdirSync(dir).sort()) {
|
|
249
|
-
if (name.startsWith('.')) continue; // .archive, .DS_Store, etc.
|
|
250
|
-
const packDir = path.join(dir, name);
|
|
251
|
-
let st: fs.Stats;
|
|
252
|
-
try {
|
|
253
|
-
st = fs.statSync(packDir);
|
|
254
|
-
} catch {
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (!st.isDirectory()) continue; // README.md and friends
|
|
258
|
-
const { manifest, error } = readPackManifest(packDir);
|
|
259
|
-
if (!manifest && !fs.existsSync(path.join(packDir, 'package.yaml'))) {
|
|
260
|
-
continue; // a plain dir that isn't a pack -- ignore silently
|
|
261
|
-
}
|
|
262
|
-
out.push({ name, dir: packDir, manifest, error });
|
|
263
|
-
}
|
|
264
|
-
return out;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Installed packs that declare `name` in their `requires.packs` (M0). Pure over
|
|
269
|
-
* the supplied list — the uninstall dependents guard calls this with
|
|
270
|
-
* `listInstalledPacks(hqRoot)`. Excludes the pack named `name` itself, so a
|
|
271
|
-
* self-reference never counts as its own dependent.
|
|
272
|
-
*/
|
|
273
|
-
export function findDependentPacks(
|
|
274
|
-
installed: InstalledPack[],
|
|
275
|
-
name: string,
|
|
276
|
-
): InstalledPack[] {
|
|
277
|
-
return installed.filter(
|
|
278
|
-
(p) =>
|
|
279
|
-
p.name !== name &&
|
|
280
|
-
(p.manifest?.requires?.packs ?? []).some((d) => d.name === name),
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ---------------------------------------------------------------------------
|
|
285
|
-
// Un-wiring (the uninstall guarantee)
|
|
286
|
-
// ---------------------------------------------------------------------------
|
|
287
|
-
|
|
288
|
-
export interface UnwireResult {
|
|
289
|
-
unlinked: Array<{ key: PackContributeKey; item: string; dst: string }>;
|
|
290
|
-
skipped: Array<{
|
|
291
|
-
key: PackContributeKey;
|
|
292
|
-
item: string;
|
|
293
|
-
dst: string;
|
|
294
|
-
reason: 'foreign' | 'missing';
|
|
295
|
-
}>;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Remove only the host symlinks that resolve into THIS pack's directory
|
|
300
|
-
* (status `live` or `broken`). Foreign links and real files are left in place
|
|
301
|
-
* -- same collision philosophy as scan-packages.sh. This is what prevents an
|
|
302
|
-
* uninstall from leaving dangling symlinks behind.
|
|
303
|
-
*/
|
|
304
|
-
export function unwirePack(
|
|
305
|
-
hqRoot: string,
|
|
306
|
-
packDir: string,
|
|
307
|
-
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
308
|
-
): UnwireResult {
|
|
309
|
-
const result: UnwireResult = { unlinked: [], skipped: [] };
|
|
310
|
-
for (const link of contributionLinks(hqRoot, packDir, contributes)) {
|
|
311
|
-
const status = linkStatus(link);
|
|
312
|
-
if (status === 'live' || status === 'broken') {
|
|
313
|
-
try {
|
|
314
|
-
fs.unlinkSync(link.dst);
|
|
315
|
-
result.unlinked.push({ key: link.key, item: link.item, dst: link.dst });
|
|
316
|
-
} catch {
|
|
317
|
-
result.skipped.push({
|
|
318
|
-
key: link.key,
|
|
319
|
-
item: link.item,
|
|
320
|
-
dst: link.dst,
|
|
321
|
-
reason: 'foreign',
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
} else {
|
|
325
|
-
result.skipped.push({
|
|
326
|
-
key: link.key,
|
|
327
|
-
item: link.item,
|
|
328
|
-
dst: link.dst,
|
|
329
|
-
reason: status === 'missing' ? 'missing' : 'foreign',
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
return result;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ---------------------------------------------------------------------------
|
|
337
|
-
// MCP un-wiring (US-009) — the `wire: 'merge'` parallel to symlink un-wiring.
|
|
338
|
-
//
|
|
339
|
-
// `unwirePack` above only removes SYMLINKS: it iterates `contributionLinks`, which
|
|
340
|
-
// SKIPS every `wire: 'merge'` key (e.g. `mcp`). So a pack's MCP servers — registered
|
|
341
|
-
// into ~/.claude.json + ~/.codex/config.toml, NOT symlinked — are INVISIBLE to the
|
|
342
|
-
// symlink unwire and would leak after uninstall. `unwirePackMcp` is the parallel
|
|
343
|
-
// path: for each name in `contributes.mcp` it calls `unregisterMcpServers` (US-009),
|
|
344
|
-
// which removes ONLY entries whose `_hqPack` provenance matches THIS pack and
|
|
345
|
-
// skip-and-warns on any foreign/unstamped same-named entry.
|
|
346
|
-
//
|
|
347
|
-
// Kept as a SEPARATE, composable call from `unwirePack` (not folded into it) so the
|
|
348
|
-
// existing `unwirePack` signature + tests stay intact and each path is testable in
|
|
349
|
-
// isolation. The uninstall command composes both (symlink unwire + MCP unwire).
|
|
350
|
-
// ---------------------------------------------------------------------------
|
|
351
|
-
|
|
352
|
-
/** The result of {@link unwirePackMcp}: the per-server un-registration outcomes (empty when no `mcp` keys). */
|
|
353
|
-
export interface UnwireMcpResult {
|
|
354
|
-
/** One {@link UnregisterServerResult} per declared `contributes.mcp` server, in declaration order. */
|
|
355
|
-
servers: UnregisterServerResult[];
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Tuning passthrough for {@link unwirePackMcp} (tests inject a tmpdir `env.home` + tiny lock timeouts). */
|
|
359
|
-
export interface UnwirePackMcpOptions {
|
|
360
|
-
/** Injectable env — tests MUST pass a tmpdir `home` so the real ~/.claude.json / ~/.codex are untouched. */
|
|
361
|
-
env?: Partial<SafeWriteEnv>;
|
|
362
|
-
/** Lock tuning (tests use tiny timeouts). */
|
|
363
|
-
lock?: AcquireLockOptions;
|
|
364
|
-
/** Backup timestamp override (deterministic tests). */
|
|
365
|
-
stamp?: string;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Un-register the `wire: 'merge'` (MCP) servers a pack declares — the parallel to
|
|
370
|
-
* {@link unwirePack}'s symlink removal. For each name in `mergeKeys(contributes)` →
|
|
371
|
-
* `contributes.<mergeKey>` (today only `mcp`), this delegates to
|
|
372
|
-
* {@link unregisterMcpServers}, which removes ONLY entries stamped with THIS pack's
|
|
373
|
-
* `_hqPack` provenance and SKIPS-AND-WARNS on a foreign/unstamped same-named entry.
|
|
374
|
-
*
|
|
375
|
-
* No-ops cleanly when the pack declares no merge keys (returns `{ servers: [] }`),
|
|
376
|
-
* when the runtime config is absent (Codex-less host → first-class skip; missing
|
|
377
|
-
* ~/.claude.json → `absent`), and on a re-run (already-removed → `absent`). It does
|
|
378
|
-
* NOT throw on a foreign entry — uninstall of one pack must not abort on a server
|
|
379
|
-
* another pack (or the user) owns.
|
|
380
|
-
*
|
|
381
|
-
* @param packName the pack being uninstalled — the provenance to match
|
|
382
|
-
* @param contributes the pack's `contributes` block (its `mcp` list drives removal)
|
|
383
|
-
*/
|
|
384
|
-
export function unwirePackMcp(
|
|
385
|
-
packName: string,
|
|
386
|
-
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
387
|
-
options?: UnwirePackMcpOptions,
|
|
388
|
-
): UnwireMcpResult {
|
|
389
|
-
const servers: UnregisterServerResult[] = [];
|
|
390
|
-
for (const key of mergeKeys(contributes)) {
|
|
391
|
-
const names = (contributes[key] ?? []).filter(
|
|
392
|
-
(n): n is string => typeof n === 'string' && n.length > 0,
|
|
393
|
-
);
|
|
394
|
-
if (names.length === 0) continue;
|
|
395
|
-
servers.push(
|
|
396
|
-
...unregisterMcpServers(packName, names, {
|
|
397
|
-
env: options?.env,
|
|
398
|
-
lock: options?.lock,
|
|
399
|
-
stamp: options?.stamp,
|
|
400
|
-
}),
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
return { servers };
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// ---------------------------------------------------------------------------
|
|
407
|
-
// Host introspection: hqVersion + recommended_packages catalog
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
|
|
410
|
-
/** Locate core.yaml -- v14+ nests it under `core/`, older layouts at the root. */
|
|
411
|
-
function coreYamlPath(hqRoot: string): string | null {
|
|
412
|
-
const nested = path.join(hqRoot, 'core', 'core.yaml');
|
|
413
|
-
if (fs.existsSync(nested)) return nested;
|
|
414
|
-
const flat = path.join(hqRoot, 'core.yaml');
|
|
415
|
-
if (fs.existsSync(flat)) return flat;
|
|
416
|
-
return null;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export function readHqVersion(hqRoot: string): string | null {
|
|
420
|
-
const p = coreYamlPath(hqRoot);
|
|
421
|
-
if (!p) return null;
|
|
422
|
-
try {
|
|
423
|
-
const c = yaml.load(fs.readFileSync(p, 'utf-8')) as { hqVersion?: string };
|
|
424
|
-
return c?.hqVersion ?? null;
|
|
425
|
-
} catch {
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
export interface CatalogEntry {
|
|
431
|
-
source: string;
|
|
432
|
-
description?: string;
|
|
433
|
-
conditional?: string;
|
|
434
|
-
auto_install?: boolean;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** Read `recommended_packages` from core.yaml (the curated content-pack catalog). */
|
|
438
|
-
export function readRecommendedPackages(hqRoot: string): CatalogEntry[] {
|
|
439
|
-
const p = coreYamlPath(hqRoot);
|
|
440
|
-
if (!p) return [];
|
|
441
|
-
try {
|
|
442
|
-
const c = yaml.load(fs.readFileSync(p, 'utf-8')) as {
|
|
443
|
-
recommended_packages?: CatalogEntry[];
|
|
444
|
-
};
|
|
445
|
-
const list = c?.recommended_packages;
|
|
446
|
-
return Array.isArray(list) ? list : [];
|
|
447
|
-
} catch {
|
|
448
|
-
return [];
|
|
449
|
-
}
|
|
450
|
-
}
|
package/src/utils/people.ts
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
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
|
-
import * as fs from "fs";
|
|
19
|
-
import * as path from "path";
|
|
20
|
-
import { parseUserYaml } from "./user-yaml-error.js";
|
|
21
|
-
|
|
22
|
-
/** A single person/member record parsed from a `people/<slug>/meta.yaml`. */
|
|
23
|
-
export interface PersonRecord {
|
|
24
|
-
/** Folder slug under `companies/<company>/people/<slug>/`. */
|
|
25
|
-
slug: string;
|
|
26
|
-
/** Display name (required field in `meta.yaml`). */
|
|
27
|
-
name: string;
|
|
28
|
-
/** Contact email, when recorded. */
|
|
29
|
-
email?: string;
|
|
30
|
-
/** "internal" (team member) | "external" (client, vendor, …). */
|
|
31
|
-
type?: string;
|
|
32
|
-
/** Role/title inside (or relative to) the company. */
|
|
33
|
-
role?: string;
|
|
34
|
-
/** External-only: the person's own organization. */
|
|
35
|
-
organization?: string;
|
|
36
|
-
/** Freeform tags for filtering. */
|
|
37
|
-
tags?: string[];
|
|
38
|
-
/** Absolute path to the `meta.yaml` this record was parsed from. */
|
|
39
|
-
source: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Company slugs map directly onto a filesystem path segment, so we validate
|
|
44
|
-
* them before joining to keep a malicious or fat-fingered `--company` value
|
|
45
|
-
* from escaping the `companies/` tree. Mirrors the slug rules used by
|
|
46
|
-
* cloud-provision.
|
|
47
|
-
*/
|
|
48
|
-
const COMPANY_SLUG_REGEX = /^[A-Za-z0-9._-]+$/;
|
|
49
|
-
const FORBIDDEN_COMPANY_SLUGS = new Set(["personal", ".", ".."]);
|
|
50
|
-
|
|
51
|
-
export function assertSafeCompanySlug(slug: string): void {
|
|
52
|
-
if (!slug || !COMPANY_SLUG_REGEX.test(slug)) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`Invalid company slug "${slug}" — must match ${COMPANY_SLUG_REGEX.source}`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
if (FORBIDDEN_COMPANY_SLUGS.has(slug)) {
|
|
58
|
-
throw new Error(`Company slug "${slug}" is reserved and cannot be used here`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Absolute path to a company's `people/` directory. */
|
|
63
|
-
export function companyPeopleDir(hqRoot: string, companySlug: string): string {
|
|
64
|
-
assertSafeCompanySlug(companySlug);
|
|
65
|
-
return path.join(hqRoot, "companies", companySlug, "people");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Parse a single `meta.yaml` body into a `PersonRecord`. Returns `null` when the
|
|
70
|
-
* file is empty, unparseable, or has no usable `name` — a person record without
|
|
71
|
-
* a name can't be listed, searched, or resolved, so it's skipped rather than
|
|
72
|
-
* surfaced as a half-row. Exported for unit testing.
|
|
73
|
-
*/
|
|
74
|
-
export function parsePersonMeta(
|
|
75
|
-
raw: string,
|
|
76
|
-
slug: string,
|
|
77
|
-
source: string,
|
|
78
|
-
): PersonRecord | null {
|
|
79
|
-
const doc = parseUserYaml<unknown>(raw, source);
|
|
80
|
-
if (!doc || typeof doc !== "object") return null;
|
|
81
|
-
const d = doc as Record<string, unknown>;
|
|
82
|
-
|
|
83
|
-
const name = typeof d.name === "string" ? d.name.trim() : "";
|
|
84
|
-
if (!name) return null;
|
|
85
|
-
|
|
86
|
-
const str = (v: unknown): string | undefined =>
|
|
87
|
-
typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
88
|
-
|
|
89
|
-
const tags = Array.isArray(d.tags)
|
|
90
|
-
? d.tags.filter((t): t is string => typeof t === "string")
|
|
91
|
-
: undefined;
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
slug,
|
|
95
|
-
name,
|
|
96
|
-
email: str(d.email),
|
|
97
|
-
type: str(d.type),
|
|
98
|
-
role: str(d.role),
|
|
99
|
-
organization: str(d.organization),
|
|
100
|
-
...(tags && tags.length > 0 ? { tags } : {}),
|
|
101
|
-
source,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* List every person recorded for ONE company. Reads
|
|
107
|
-
* `companies/<companySlug>/people/<personSlug>/meta.yaml` for each person
|
|
108
|
-
* folder.
|
|
109
|
-
*
|
|
110
|
-
* - Folders whose name starts with `_` are skipped (e.g. the `_example`
|
|
111
|
-
* template that ships in `companies/_template/people/`).
|
|
112
|
-
* - Folders without a `meta.yaml`, or whose `meta.yaml` has no `name`, are
|
|
113
|
-
* skipped silently — they aren't members yet.
|
|
114
|
-
* - Returns `[]` when the company has no `people/` directory at all.
|
|
115
|
-
*
|
|
116
|
-
* Results are sorted by name (case-insensitive) for stable output.
|
|
117
|
-
*/
|
|
118
|
-
export function listCompanyPeople(
|
|
119
|
-
hqRoot: string,
|
|
120
|
-
companySlug: string,
|
|
121
|
-
): PersonRecord[] {
|
|
122
|
-
const dir = companyPeopleDir(hqRoot, companySlug);
|
|
123
|
-
if (!fs.existsSync(dir)) return [];
|
|
124
|
-
|
|
125
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
126
|
-
const people: PersonRecord[] = [];
|
|
127
|
-
|
|
128
|
-
for (const entry of entries) {
|
|
129
|
-
if (!entry.isDirectory()) continue;
|
|
130
|
-
if (entry.name.startsWith("_")) continue; // _example and other scaffolding
|
|
131
|
-
|
|
132
|
-
const metaPath = path.join(dir, entry.name, "meta.yaml");
|
|
133
|
-
if (!fs.existsSync(metaPath)) continue;
|
|
134
|
-
|
|
135
|
-
const raw = fs.readFileSync(metaPath, "utf-8");
|
|
136
|
-
const record = parsePersonMeta(raw, entry.name, metaPath);
|
|
137
|
-
if (record) people.push(record);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
people.sort((a, b) =>
|
|
141
|
-
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
|
142
|
-
);
|
|
143
|
-
return people;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Case-insensitive keyword search over a person's NAME and EMAIL (plus the
|
|
148
|
-
* folder slug, which is a normalized alias of the name). Pure — operates on an
|
|
149
|
-
* already-loaded list so it's trivially testable and reusable.
|
|
150
|
-
*
|
|
151
|
-
* An empty/whitespace keyword matches nothing (callers should treat that as a
|
|
152
|
-
* usage error rather than "return everyone").
|
|
153
|
-
*/
|
|
154
|
-
export function searchPeople(
|
|
155
|
-
people: PersonRecord[],
|
|
156
|
-
keyword: string,
|
|
157
|
-
): PersonRecord[] {
|
|
158
|
-
const needle = keyword.trim().toLowerCase();
|
|
159
|
-
if (!needle) return [];
|
|
160
|
-
return people.filter((p) => {
|
|
161
|
-
const haystacks = [p.name, p.email, p.slug].filter(
|
|
162
|
-
(v): v is string => typeof v === "string",
|
|
163
|
-
);
|
|
164
|
-
return haystacks.some((h) => h.toLowerCase().includes(needle));
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Outcome of resolving a name to an email address. */
|
|
169
|
-
export type ResolveResult =
|
|
170
|
-
| { status: "found"; email: string; person: PersonRecord }
|
|
171
|
-
| { status: "no_email"; person: PersonRecord }
|
|
172
|
-
| { status: "ambiguous"; matches: PersonRecord[] }
|
|
173
|
-
| { status: "not_found" };
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Resolve a person NAME to their email, built on top of {@link searchPeople}.
|
|
177
|
-
*
|
|
178
|
-
* Match precedence (narrowest first) so a precise query isn't drowned out by
|
|
179
|
-
* looser substring hits:
|
|
180
|
-
* 1. exact name match (case-insensitive, trimmed)
|
|
181
|
-
* 2. exact folder-slug match
|
|
182
|
-
* 3. substring search over name/email/slug
|
|
183
|
-
*
|
|
184
|
-
* The first tier that yields any match decides the result:
|
|
185
|
-
* - exactly one match with an email → `found`
|
|
186
|
-
* - exactly one match, no email → `no_email`
|
|
187
|
-
* - more than one match → `ambiguous` (caller disambiguates)
|
|
188
|
-
* - no match in any tier → `not_found`
|
|
189
|
-
*/
|
|
190
|
-
export function resolveNameToEmail(
|
|
191
|
-
people: PersonRecord[],
|
|
192
|
-
name: string,
|
|
193
|
-
): ResolveResult {
|
|
194
|
-
const query = name.trim();
|
|
195
|
-
if (!query) return { status: "not_found" };
|
|
196
|
-
const lowered = query.toLowerCase();
|
|
197
|
-
|
|
198
|
-
const exactName = people.filter((p) => p.name.toLowerCase() === lowered);
|
|
199
|
-
const exactSlug = people.filter((p) => p.slug.toLowerCase() === lowered);
|
|
200
|
-
const substring = searchPeople(people, query);
|
|
201
|
-
|
|
202
|
-
const matches =
|
|
203
|
-
exactName.length > 0
|
|
204
|
-
? exactName
|
|
205
|
-
: exactSlug.length > 0
|
|
206
|
-
? exactSlug
|
|
207
|
-
: substring;
|
|
208
|
-
|
|
209
|
-
if (matches.length === 0) return { status: "not_found" };
|
|
210
|
-
if (matches.length > 1) return { status: "ambiguous", matches };
|
|
211
|
-
|
|
212
|
-
const person = matches[0];
|
|
213
|
-
if (!person.email) return { status: "no_email", person };
|
|
214
|
-
return { status: "found", email: person.email, person };
|
|
215
|
-
}
|