@indigoai-us/hq-cli 5.345.155 → 5.345.159

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 (169) hide show
  1. package/CHANGELOG.md +289 -0
  2. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +110 -2
  3. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +101 -5
  4. package/assets/scaffold/core/scripts/worktree.sh +4 -0
  5. package/dist/command-catalog.generated.d.ts +536 -7
  6. package/dist/command-catalog.generated.js +676 -24
  7. package/dist/command-registration-plan.d.ts +18 -0
  8. package/dist/command-registration-plan.js +4 -0
  9. package/dist/commands/_patterns.d.ts +17 -0
  10. package/dist/commands/_patterns.js +22 -0
  11. package/dist/commands/agent-inbox.js +2 -2
  12. package/dist/commands/agent-kit.js +8 -6
  13. package/dist/commands/bot.js +7 -4
  14. package/dist/commands/channel-suggest.d.ts +56 -0
  15. package/dist/commands/channel-suggest.js +170 -0
  16. package/dist/commands/channels.js +7 -1
  17. package/dist/commands/cloud.d.ts +45 -0
  18. package/dist/commands/cloud.js +150 -10
  19. package/dist/commands/company.js +4 -3
  20. package/dist/commands/core-checkpoint-native.d.ts +18 -0
  21. package/dist/commands/core-checkpoint-native.js +67 -2
  22. package/dist/commands/core-pipeline-driver-native.d.ts +12 -1
  23. package/dist/commands/core-pipeline-driver-native.js +131 -5
  24. package/dist/commands/core.js +13 -1
  25. package/dist/commands/crm.js +83 -6
  26. package/dist/commands/deploy-access.d.ts +33 -0
  27. package/dist/commands/deploy-access.js +322 -0
  28. package/dist/commands/dm-attach.d.ts +106 -0
  29. package/dist/commands/dm-attach.js +184 -0
  30. package/dist/commands/dm.d.ts +37 -0
  31. package/dist/commands/dm.js +172 -10
  32. package/dist/commands/feedback-show.d.ts +40 -0
  33. package/dist/commands/feedback-show.js +261 -0
  34. package/dist/commands/feedback.d.ts +21 -0
  35. package/dist/commands/feedback.js +152 -2
  36. package/dist/commands/files-browse.d.ts +16 -0
  37. package/dist/commands/files-browse.js +52 -1
  38. package/dist/commands/files.d.ts +24 -0
  39. package/dist/commands/files.js +52 -1
  40. package/dist/commands/integrations-connect.js +7 -3
  41. package/dist/commands/integrations-core.d.ts +13 -1
  42. package/dist/commands/integrations-core.js +49 -4
  43. package/dist/commands/integrations.js +4 -2
  44. package/dist/commands/lanes-accounts.js +16 -1
  45. package/dist/commands/lanes-loop.js +14 -4
  46. package/dist/commands/lanes-message.js +6 -4
  47. package/dist/commands/lanes-resume.js +36 -13
  48. package/dist/commands/lanes-shared.d.ts +5 -0
  49. package/dist/commands/lanes-shared.js +13 -3
  50. package/dist/commands/lanes-watch.js +1 -0
  51. package/dist/commands/lanes.d.ts +20 -1
  52. package/dist/commands/lanes.js +201 -51
  53. package/dist/commands/mesh.js +149 -1
  54. package/dist/commands/monitor.d.ts +1 -0
  55. package/dist/commands/monitor.js +4 -1
  56. package/dist/commands/repo.d.ts +41 -0
  57. package/dist/commands/repo.js +331 -0
  58. package/dist/commands/scan.d.ts +3 -1
  59. package/dist/commands/scan.js +7 -3
  60. package/dist/commands/secrets.d.ts +37 -0
  61. package/dist/commands/secrets.js +185 -16
  62. package/dist/commands/signals.js +17 -5
  63. package/dist/commands/staff/listings.d.ts +41 -0
  64. package/dist/commands/staff/listings.js +160 -0
  65. package/dist/commands/staff/reset-test-account.d.ts +3 -2
  66. package/dist/commands/staff/reset-test-account.js +4 -3
  67. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2629 -1036
  68. package/dist/hq-cloud-sync-runner/package.json +1 -1
  69. package/dist/lib/agent-kit/inbox-state.d.ts +7 -0
  70. package/dist/lib/agent-kit/inbox-state.js +5 -1
  71. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  72. package/dist/lib/agent-kit/run/inbox.js +2 -1
  73. package/dist/lib/agent-kit/wake-secrets.d.ts +7 -0
  74. package/dist/lib/agent-kit/wake-secrets.js +32 -0
  75. package/dist/lib/agent-kit/wake.d.ts +4 -0
  76. package/dist/lib/agent-kit/wake.js +49 -3
  77. package/dist/lib/bot/inflight.d.ts +7 -1
  78. package/dist/lib/bot/inflight.js +10 -2
  79. package/dist/lib/bot/prompt.js +3 -0
  80. package/dist/lib/bot/run.js +72 -20
  81. package/dist/lib/bot/session.d.ts +14 -0
  82. package/dist/lib/bot/session.js +70 -0
  83. package/dist/lib/capacity/collectors/claude-oauth.d.ts +6 -0
  84. package/dist/lib/capacity/collectors/claude-oauth.js +12 -0
  85. package/dist/lib/capacity/lane-login.d.ts +6 -0
  86. package/dist/lib/capacity/lane-login.js +5 -0
  87. package/dist/lib/core-utils/worktree.js +2 -0
  88. package/dist/lib/daemon/exit-cause.d.ts +43 -0
  89. package/dist/lib/daemon/exit-cause.js +88 -0
  90. package/dist/lib/daemon/handoff.d.ts +2 -0
  91. package/dist/lib/daemon/handoff.js +6 -1
  92. package/dist/lib/daemon/hq-child-bootstrap.mjs +20 -3
  93. package/dist/lib/daemon/paths.d.ts +2 -0
  94. package/dist/lib/daemon/paths.js +1 -0
  95. package/dist/lib/daemon/run.js +13 -0
  96. package/dist/lib/daemon/supervisor.d.ts +54 -1
  97. package/dist/lib/daemon/supervisor.js +297 -21
  98. package/dist/lib/daemon/unit-holds.d.ts +22 -0
  99. package/dist/lib/daemon/unit-holds.js +86 -0
  100. package/dist/lib/doctor/checks/sync-health.d.ts +9 -0
  101. package/dist/lib/doctor/checks/sync-health.js +66 -1
  102. package/dist/lib/doctor/company-resolution-state.d.ts +30 -0
  103. package/dist/lib/doctor/company-resolution-state.js +89 -0
  104. package/dist/lib/doctor/fix/apply.d.ts +36 -6
  105. package/dist/lib/doctor/fix/apply.js +128 -12
  106. package/dist/lib/doctor/types.d.ts +1 -1
  107. package/dist/lib/doctor/types.js +1 -0
  108. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  109. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  110. package/dist/lib/hook-engine/resolve.js +6 -2
  111. package/dist/lib/index-render/company-knowledge.d.ts +1 -1
  112. package/dist/lib/index-render/company-knowledge.js +4 -5
  113. package/dist/lib/index-render/index.js +44 -3
  114. package/dist/lib/index-render/projects.d.ts +1 -1
  115. package/dist/lib/index-render/projects.js +5 -5
  116. package/dist/lib/lanes/account-company.d.ts +28 -0
  117. package/dist/lib/lanes/account-company.js +53 -0
  118. package/dist/lib/lanes/accounts.d.ts +22 -3
  119. package/dist/lib/lanes/accounts.js +38 -2
  120. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  121. package/dist/lib/lanes/claude-balance.js +6 -1
  122. package/dist/lib/lanes/claude-credential.d.ts +78 -0
  123. package/dist/lib/lanes/claude-credential.js +163 -0
  124. package/dist/lib/lanes/compaction.d.ts +2 -0
  125. package/dist/lib/lanes/compaction.js +9 -0
  126. package/dist/lib/lanes/config.js +6 -0
  127. package/dist/lib/lanes/envelope-repair-dispatch.js +2 -1
  128. package/dist/lib/lanes/hooks-install.js +2 -1
  129. package/dist/lib/lanes/host-capacity.d.ts +2 -0
  130. package/dist/lib/lanes/host-capacity.js +1 -0
  131. package/dist/lib/lanes/keep-alive.d.ts +1 -1
  132. package/dist/lib/lanes/keep-alive.js +3 -1
  133. package/dist/lib/lanes/links.js +10 -4
  134. package/dist/lib/lanes/loop.js +16 -1
  135. package/dist/lib/lanes/node-binary.d.ts +17 -0
  136. package/dist/lib/lanes/node-binary.js +30 -0
  137. package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
  138. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  139. package/dist/lib/lanes/reconcile.js +4 -0
  140. package/dist/lib/lanes/remote-control-lane.js +2 -0
  141. package/dist/lib/lanes/spawn.d.ts +3 -0
  142. package/dist/lib/lanes/spawn.js +119 -9
  143. package/dist/lib/lanes/store.d.ts +6 -0
  144. package/dist/lib/lanes/store.js +107 -18
  145. package/dist/lib/lanes/types.d.ts +2 -0
  146. package/dist/lib/lanes/watch.js +28 -5
  147. package/dist/lib/lanes/watcher-main.js +4 -3
  148. package/dist/lib/lanes/watcher.d.ts +4 -0
  149. package/dist/lib/lanes/watcher.js +110 -21
  150. package/dist/lib/mesh/api.d.ts +32 -0
  151. package/dist/lib/mesh/api.js +75 -0
  152. package/dist/lib/secret-scan/detect.js +9 -0
  153. package/dist/lib/secret-scan/repo-config.d.ts +41 -0
  154. package/dist/lib/secret-scan/repo-config.js +237 -0
  155. package/dist/lib/secret-scan/rules.d.ts +7 -1
  156. package/dist/lib/secret-scan/rules.js +3 -1
  157. package/dist/lib/secret-scan/staged.d.ts +5 -0
  158. package/dist/lib/secret-scan/staged.js +19 -2
  159. package/dist/lib/secret-scan/types.d.ts +2 -0
  160. package/dist/lib/sync/server-owned-local-only.d.ts +29 -0
  161. package/dist/lib/sync/server-owned-local-only.js +75 -0
  162. package/dist/sentry-before-send.js +1 -0
  163. package/dist/utils/access-denied-hint.js +6 -0
  164. package/dist/utils/client-health.d.ts +34 -0
  165. package/dist/utils/client-health.js +96 -3
  166. package/dist/utils/package-use-lease.js +27 -9
  167. package/dist/utils/version-gate.d.ts +7 -0
  168. package/dist/utils/version-gate.js +66 -0
  169. package/package.json +8 -8
@@ -13,7 +13,7 @@ import { CLI_VERSION } from "../cli-version.js";
13
13
  import chalk from "chalk";
14
14
  import { ensureCognitoToken, isExpiring, isMachineIdentity, loadCachedTokens, } from "../utils/cognito-session.js";
15
15
  import * as readline from "node:readline/promises";
16
- import { STORY_STATUSES, appendThreadEvent, addProjectMember, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, meshJson, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
16
+ import { STORY_STATUSES, appendThreadEvent, addProjectMember, ensureProjectThread, eventPayload, getProjectLifecycle, getProjectView, listActiveMembershipCompanies, listActiveThreads, listProjectLifecycles, meshJson, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, setProjectLifecycle, warmMeshConversationCache, } from "../lib/mesh/api.js";
17
17
  import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, boardStoryStatus, formatStoryStatusChanges, mergeProjectStories, prdSyncEventsThatChangeBoard, reconcileProjectStories, } from "../lib/mesh/project-board.js";
18
18
  import { ACCEPTED_LEDGER_STATES, mapSourceStoryStatus, UnknownProjectStoryStateError } from "../lib/mesh/story-status.js";
19
19
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
@@ -665,6 +665,130 @@ async function runProjectMemberAdd(projectId, personUid, opts) {
665
665
  ? `Work mesh: added ${personUid} to project ${projectId}`
666
666
  : `Work mesh: ${personUid} is already a member of project ${projectId}`);
667
667
  }
668
+ const LIFECYCLE_VERBS = {
669
+ archived: "archive",
670
+ completed: "complete",
671
+ active: "activate",
672
+ };
673
+ /**
674
+ * `hq mesh project archive|complete|activate <projectId>` (feedback 5d596dd0).
675
+ *
676
+ * Moves a Board between active, completed and archived through the server's
677
+ * lifecycle route. Nothing is deleted: stories, the project folder, channel,
678
+ * memberships and history all stay. An archived or completed project leaves
679
+ * the active Board list and the organize candidates, which is how a duplicate
680
+ * Board (one registered under a board id, one under the folder slug) is put
681
+ * away without touching the live one.
682
+ */
683
+ async function runProjectLifecycle(projectId, status, opts) {
684
+ const verb = LIFECYCLE_VERBS[status];
685
+ const id = projectId.trim();
686
+ if (!id)
687
+ fail(`project ${verb} requires a project id`);
688
+ let expectedVersion;
689
+ if (opts.expectedVersion !== undefined) {
690
+ expectedVersion = Number.parseInt(opts.expectedVersion, 10);
691
+ if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0) {
692
+ fail("--expected-version must be a non-negative integer");
693
+ }
694
+ }
695
+ const { token, company } = await withCompany(opts);
696
+ const companyRef = company.companySlug || company.companyUid;
697
+ let current;
698
+ try {
699
+ current = await getProjectLifecycle(token, company.companyUid, id);
700
+ }
701
+ catch (err) {
702
+ if (isRouteUnavailable(err))
703
+ return reportUnavailable("Project lifecycle", `project.${verb}`, opts.json);
704
+ throw err;
705
+ }
706
+ if (current && current.status === status) {
707
+ if (opts.json) {
708
+ console.log(JSON.stringify({ ok: true, action: `project.${verb}`, changed: false, company, ...current }, null, 2));
709
+ return;
710
+ }
711
+ console.log(`Work mesh: project ${id} is already ${status} in company ${companyRef} (nothing changed)`);
712
+ return;
713
+ }
714
+ // The server's OCC version is the lifecycle row version, not the Board version.
715
+ const version = expectedVersion ?? current?.version ?? 0;
716
+ let result;
717
+ try {
718
+ result = await setProjectLifecycle(token, company.companyUid, id, {
719
+ status,
720
+ expectedVersion: version,
721
+ ...(opts.confirmNonempty ? { confirmNonempty: true } : {}),
722
+ });
723
+ }
724
+ catch (err) {
725
+ const failure = err;
726
+ const body = (failure.body && typeof failure.body === "object" ? failure.body : {});
727
+ // The list GET above already answered, so the lifecycle routes exist on
728
+ // this server; a 404 here is the project, not the route. Only 405 means
729
+ // the PATCH is missing.
730
+ if (failure.status === 405)
731
+ return reportUnavailable("Project lifecycle", `project.${verb}`, opts.json);
732
+ if (failure.status === 404) {
733
+ fail(`Work mesh project ${id} was not found in company ${companyRef}. Check the id with "hq mesh project lifecycle --company ${companyRef}" or the Board.`);
734
+ }
735
+ if (failure.status === 409 && failure.code === "BOARD_NONEMPTY") {
736
+ const count = Number.isFinite(Number(body.storyCount)) ? Number(body.storyCount) : undefined;
737
+ fail(`Work mesh project ${id} still has ${count !== undefined ? `${count} ` : ""}stor${count === 1 ? "y" : "ies"} on its Board. ` +
738
+ `Re-run with --confirm-nonempty to archive it anyway; the stories, project folder, channel, memberships and history are kept.`);
739
+ }
740
+ if (failure.status === 409) {
741
+ fail(`Work mesh project ${id} lifecycle changed since it was read (expected version ${version}). Re-run the command to retry against the current state.`);
742
+ }
743
+ throw err;
744
+ }
745
+ if (opts.json) {
746
+ console.log(JSON.stringify({ ok: true, action: `project.${verb}`, changed: true, company, previousStatus: current?.status ?? "active", ...result }, null, 2));
747
+ return;
748
+ }
749
+ const detail = status === "active"
750
+ ? "it is back on the active Board list and in organize candidates"
751
+ : "it leaves the active Board list and organize candidates; stories, folder, channel, memberships and history are kept";
752
+ console.log(`Work mesh: project ${result.projectId} is now ${result.status} in company ${companyRef} (${detail})`);
753
+ }
754
+ /** `hq mesh project lifecycle [projectId]`: list projects that are not active, or show one. */
755
+ async function runProjectLifecycleList(projectId, opts) {
756
+ const { token, company } = await withCompany(opts);
757
+ const companyRef = company.companySlug || company.companyUid;
758
+ let rows;
759
+ try {
760
+ rows = await listProjectLifecycles(token, company.companyUid);
761
+ }
762
+ catch (err) {
763
+ if (isRouteUnavailable(err))
764
+ return reportUnavailable("Project lifecycle", "project.lifecycle", opts.json);
765
+ throw err;
766
+ }
767
+ const id = projectId?.trim();
768
+ if (id) {
769
+ const row = rows.find((r) => r.projectId === id) ?? null;
770
+ if (opts.json) {
771
+ console.log(JSON.stringify({ ok: true, action: "project.lifecycle", company, projectId: id, lifecycle: row ?? { projectId: id, status: "active", version: 0 } }, null, 2));
772
+ return;
773
+ }
774
+ console.log(row
775
+ ? `Work mesh: project ${id} is ${row.status} in company ${companyRef} (lifecycle version ${row.version}, updated ${row.updatedAt || "unknown"})`
776
+ : `Work mesh: project ${id} is active in company ${companyRef} (no lifecycle change recorded)`);
777
+ return;
778
+ }
779
+ if (opts.json) {
780
+ console.log(JSON.stringify({ ok: true, action: "project.lifecycle", company, projects: rows }, null, 2));
781
+ return;
782
+ }
783
+ if (!rows.length) {
784
+ console.log(`Work mesh: no project in company ${companyRef} has been completed or archived`);
785
+ return;
786
+ }
787
+ console.log(`Work mesh: project lifecycle in company ${companyRef}`);
788
+ for (const row of rows.sort((a, b) => a.projectId.localeCompare(b.projectId))) {
789
+ console.log(` ${row.projectId} ${row.status} v${row.version} ${row.updatedAt || ""}`.trimEnd());
790
+ }
791
+ }
668
792
  /**
669
793
  * Create or merge the project Board view from a parsed PRD, register the
670
794
  * project, and verify every PRD story and repo is on the Board. Shared by
@@ -2690,6 +2814,30 @@ export function registerMeshCommand(program, deps = {}) {
2690
2814
  .option("--note <text>", "Handoff note")
2691
2815
  .option("--json", "Print machine-readable JSON")
2692
2816
  .action((projectId, opts) => wrap(() => runProjectReassign(projectId, opts))());
2817
+ for (const status of ["archived", "completed", "active"]) {
2818
+ const verb = LIFECYCLE_VERBS[status];
2819
+ const description = status === "archived"
2820
+ ? "Archive a Board (for example a duplicate); keeps stories, folder, channel, members and history"
2821
+ : status === "completed"
2822
+ ? "Mark a Board completed; it leaves the active list and organize candidates"
2823
+ : "Restore a completed or archived Board to active";
2824
+ const cmd = project
2825
+ .command(`${verb} <projectId>`)
2826
+ .description(description)
2827
+ .requiredOption("--company <slug|uid>", "Company slug or cloud uid")
2828
+ .option("--expected-version <n>", "Lifecycle row version to replace (defaults to the current one)")
2829
+ .option("--json", "Print machine-readable JSON");
2830
+ if (status === "archived") {
2831
+ cmd.option("--confirm-nonempty", "Archive even when the Board still has stories");
2832
+ }
2833
+ cmd.action((projectId, opts) => wrap(() => runProjectLifecycle(projectId, status, opts))());
2834
+ }
2835
+ project
2836
+ .command("lifecycle [projectId]")
2837
+ .description("List completed and archived Boards, or show one project's lifecycle status")
2838
+ .requiredOption("--company <slug|uid>", "Company slug or cloud uid")
2839
+ .option("--json", "Print machine-readable JSON")
2840
+ .action((projectId, opts) => wrap(() => runProjectLifecycleList(projectId, opts))());
2693
2841
  project
2694
2842
  .command("member")
2695
2843
  .description("Manage project members")
@@ -21,6 +21,7 @@ export declare function runMonitorEnabledCommand(deps?: {
21
21
  writeError?: (text: string) => void;
22
22
  }): Promise<number>;
23
23
  export declare function armText(meta: MonitorMetadata): string;
24
+ export declare function expiredText(id: string, timeoutMs: number, events: number): string;
24
25
  export declare function resolveMonitorStartTarget(hqRoot: string, discovered: MonitorTarget, explicitlyProvided: boolean, runtime?: MonitorRuntimeIdentity): MonitorTarget;
25
26
  export declare function invokeLaneMonitorMessage(hqRoot: string, meta: MonitorMetadata, sequence: number, text: string, inProcess?: MonitorLaneMessageApi): {
26
27
  delivered: boolean;
@@ -168,6 +168,9 @@ function formatDuration(milliseconds) {
168
168
  return `${milliseconds / 1_000}s`;
169
169
  return `${milliseconds}ms`;
170
170
  }
171
+ export function expiredText(id, timeoutMs, events) {
172
+ return `[hq monitor ${id} expired after ${formatDuration(timeoutMs)} with ${events} events delivered. Re-arm with \`hq monitor start ... --timeout <duration>\` (for example --timeout 30m) or \`--persistent\` if you still need the watch — and widen the filter if silence was unexpected.]`;
173
+ }
171
174
  function monitorTimeout(timeoutMs, onTimeout) {
172
175
  const startedAt = performance.now();
173
176
  let timer = null;
@@ -711,7 +714,7 @@ export async function runSupervisor(id, deps = {}) {
711
714
  const priorEvents = state.event_count;
712
715
  if (reason !== "stopped" && reason !== "target_ended" && reason !== "supervisor_died") {
713
716
  const terminalText = reason === "expired"
714
- ? `[hq monitor ${id} expired after ${formatDuration(meta.timeout_ms ?? MONITOR_DEFAULT_TIMEOUT_MS)} with ${priorEvents} events delivered. Re-arm with \`hq monitor start ...\` if you still need the watch — and widen the filter if silence was unexpected.]`
717
+ ? expiredText(id, meta.timeout_ms ?? MONITOR_DEFAULT_TIMEOUT_MS, priorEvents)
715
718
  : reason === "rate_exceeded"
716
719
  ? `[hq monitor ${id} stopped: rate_exceeded after suppressing sustained output.]`
717
720
  : `[hq monitor ${id} ${JSON.stringify(meta.description)} exited with code ${exitCode ?? 1} after ${priorEvents} events]`;
@@ -0,0 +1,41 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `hq repo add <path> --company <slug>` — register a git checkout with HQ:
4
+ * link it into `repos/<private|public>/<name>` and list it under
5
+ * `companies.<slug>.repos` in `companies/manifest.yaml`.
6
+ *
7
+ * The manifest edit is a line insert, not a YAML re-dump, so comments,
8
+ * ordering, and quoting in the rest of the file are left as they were. The
9
+ * result is parsed back before writing; if the entry does not show up where
10
+ * expected, nothing is written.
11
+ */
12
+ export declare const MANIFEST_RELATIVE_PATH: string;
13
+ export declare function manifestRepos(text: string, company: string): string[] | null;
14
+ /**
15
+ * Companies other than `company` whose `repos` list contains one of `entries`.
16
+ */
17
+ export declare function otherCompaniesClaiming(text: string, company: string, entries: string[]): string[];
18
+ /**
19
+ * Return manifest text with `entry` appended to `companies.<company>.repos`.
20
+ * Returns the input unchanged when the entry is already listed. Throws when
21
+ * the company is not in the manifest or the edit cannot be placed. Pure.
22
+ */
23
+ export declare function addRepoToManifestText(text: string, company: string, entry: string): string;
24
+ export interface RepoAddOptions {
25
+ source: string;
26
+ company: string;
27
+ hqRoot: string;
28
+ visibility: "private" | "public";
29
+ name?: string;
30
+ dryRun?: boolean;
31
+ }
32
+ export interface RepoAddResult {
33
+ entry: string;
34
+ linked: boolean;
35
+ manifestChanged: boolean;
36
+ warnings: string[];
37
+ }
38
+ /** Register a checkout. Throws a user-facing Error on any refusal. */
39
+ export declare function runRepoAdd(opts: RepoAddOptions): RepoAddResult;
40
+ export declare function registerRepoCommand(program: Command): void;
41
+ //# sourceMappingURL=repo.d.ts.map
@@ -0,0 +1,331 @@
1
+ import chalk from "chalk";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as yaml from "js-yaml";
5
+ import { findHqRoot } from "../utils/manifest.js";
6
+ /**
7
+ * `hq repo add <path> --company <slug>` — register a git checkout with HQ:
8
+ * link it into `repos/<private|public>/<name>` and list it under
9
+ * `companies.<slug>.repos` in `companies/manifest.yaml`.
10
+ *
11
+ * The manifest edit is a line insert, not a YAML re-dump, so comments,
12
+ * ordering, and quoting in the rest of the file are left as they were. The
13
+ * result is parsed back before writing; if the entry does not show up where
14
+ * expected, nothing is written.
15
+ */
16
+ export const MANIFEST_RELATIVE_PATH = path.join("companies", "manifest.yaml");
17
+ function indentOf(line) {
18
+ return line.length - line.trimStart().length;
19
+ }
20
+ function isContentLine(line) {
21
+ const t = line.trim();
22
+ return t !== "" && !t.startsWith("#");
23
+ }
24
+ function escapeRegExp(s) {
25
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
+ }
27
+ function parseManifest(text) {
28
+ const parsed = yaml.load(text);
29
+ if (!parsed || typeof parsed !== "object") {
30
+ throw new Error("companies/manifest.yaml is empty or not a mapping.");
31
+ }
32
+ return parsed;
33
+ }
34
+ export function manifestRepos(text, company) {
35
+ const doc = parseManifest(text);
36
+ const entry = doc.companies?.[company];
37
+ if (entry === undefined)
38
+ return null;
39
+ const repos = entry?.repos;
40
+ return Array.isArray(repos) ? repos.map(String) : [];
41
+ }
42
+ /**
43
+ * Companies other than `company` whose `repos` list contains one of `entries`.
44
+ */
45
+ export function otherCompaniesClaiming(text, company, entries) {
46
+ const doc = parseManifest(text);
47
+ const out = [];
48
+ for (const [slug, block] of Object.entries(doc.companies ?? {})) {
49
+ if (slug === company)
50
+ continue;
51
+ const repos = block?.repos;
52
+ if (Array.isArray(repos) && repos.map(String).some((r) => entries.includes(r))) {
53
+ out.push(slug);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ /**
59
+ * Return manifest text with `entry` appended to `companies.<company>.repos`.
60
+ * Returns the input unchanged when the entry is already listed. Throws when
61
+ * the company is not in the manifest or the edit cannot be placed. Pure.
62
+ */
63
+ export function addRepoToManifestText(text, company, entry) {
64
+ const before = manifestRepos(text, company);
65
+ if (before === null) {
66
+ throw new Error(`Company '${company}' is not in companies/manifest.yaml. Add the company first (/newcompany).`);
67
+ }
68
+ if (before.includes(entry))
69
+ return text;
70
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
71
+ const lines = text.split(/\r?\n/);
72
+ const companiesIdx = lines.findIndex((l) => /^companies:\s*(#.*)?$/.test(l));
73
+ if (companiesIdx === -1) {
74
+ throw new Error("companies/manifest.yaml has no top-level `companies:` key.");
75
+ }
76
+ const companyRe = new RegExp(`^(\\s+)(["']?)${escapeRegExp(company)}\\2:\\s*(#.*)?$`);
77
+ let companyIdx = -1;
78
+ for (let i = companiesIdx + 1; i < lines.length; i++) {
79
+ if (isContentLine(lines[i]) && indentOf(lines[i]) === 0)
80
+ break;
81
+ if (companyRe.test(lines[i])) {
82
+ companyIdx = i;
83
+ break;
84
+ }
85
+ }
86
+ if (companyIdx === -1) {
87
+ throw new Error(`Could not find the '${company}:' block in companies/manifest.yaml to edit.`);
88
+ }
89
+ const ci = indentOf(lines[companyIdx]);
90
+ let blockEnd = lines.length; // exclusive
91
+ for (let i = companyIdx + 1; i < lines.length; i++) {
92
+ if (isContentLine(lines[i]) && indentOf(lines[i]) <= ci) {
93
+ blockEnd = i;
94
+ break;
95
+ }
96
+ }
97
+ let lastContent = companyIdx;
98
+ for (let i = companyIdx + 1; i < blockEnd; i++) {
99
+ if (isContentLine(lines[i]))
100
+ lastContent = i;
101
+ }
102
+ for (let i = companyIdx + 1; i < blockEnd; i++) {
103
+ if (/^\s+repos:\s*\[\s*[^\]\s]/.test(lines[i]) && indentOf(lines[i]) > ci) {
104
+ throw new Error(`companies.${company}.repos is an inline list (repos: [...]), which this command does not edit. Add the entry by hand.`);
105
+ }
106
+ }
107
+ const out = [...lines];
108
+ const reposIdx = (() => {
109
+ for (let i = companyIdx + 1; i < blockEnd; i++) {
110
+ if (/^\s+repos:\s*(\[\s*\])?\s*(#.*)?$/.test(lines[i]) && indentOf(lines[i]) > ci) {
111
+ return i;
112
+ }
113
+ }
114
+ return -1;
115
+ })();
116
+ if (reposIdx === -1) {
117
+ const keyIndent = " ".repeat(ci + 2);
118
+ out.splice(lastContent + 1, 0, `${keyIndent}repos:`, `${keyIndent} - ${entry}`);
119
+ }
120
+ else {
121
+ const ri = indentOf(lines[reposIdx]);
122
+ if (/\[\s*\]/.test(lines[reposIdx])) {
123
+ out[reposIdx] = `${" ".repeat(ri)}repos:`;
124
+ out.splice(reposIdx + 1, 0, `${" ".repeat(ri + 2)}- ${entry}`);
125
+ }
126
+ else {
127
+ let lastItem = -1;
128
+ let itemPrefix = `${" ".repeat(ri + 2)}- `;
129
+ for (let i = reposIdx + 1; i < blockEnd; i++) {
130
+ const l = lines[i];
131
+ if (!isContentLine(l))
132
+ continue;
133
+ const ind = indentOf(l);
134
+ if (ind < ri || (ind === ri && !l.trimStart().startsWith("- ")))
135
+ break;
136
+ if (l.trimStart().startsWith("- ")) {
137
+ lastItem = i;
138
+ itemPrefix = `${" ".repeat(ind)}- `;
139
+ }
140
+ else if (ind <= ri) {
141
+ break;
142
+ }
143
+ }
144
+ out.splice((lastItem === -1 ? reposIdx : lastItem) + 1, 0, `${itemPrefix}${entry}`);
145
+ }
146
+ }
147
+ const next = out.join(eol);
148
+ const after = manifestRepos(next, company);
149
+ if (!after || !after.includes(entry) || after.length !== before.length + 1) {
150
+ throw new Error("Could not update companies/manifest.yaml safely (unusual layout). Add the entry by hand.");
151
+ }
152
+ return next;
153
+ }
154
+ function isInside(parent, child) {
155
+ const rel = path.relative(parent, child);
156
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
157
+ }
158
+ /**
159
+ * Write `text` to `file` through a temp file in the same directory and a
160
+ * rename, so a crash cannot leave a truncated manifest. Refuses when the file
161
+ * no longer holds `expected` (another writer edited it since it was read).
162
+ */
163
+ function writeManifestAtomically(file, expected, text) {
164
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
165
+ fs.writeFileSync(tmp, text);
166
+ try {
167
+ if (fs.readFileSync(file, "utf8") !== expected) {
168
+ throw new Error("companies/manifest.yaml changed while this command was running. Nothing was written to it; run the command again.");
169
+ }
170
+ fs.renameSync(tmp, file);
171
+ }
172
+ catch (err) {
173
+ fs.rmSync(tmp, { force: true });
174
+ throw err;
175
+ }
176
+ }
177
+ function isGitCheckout(dir) {
178
+ return fs.existsSync(path.join(dir, ".git"));
179
+ }
180
+ /** Register a checkout. Throws a user-facing Error on any refusal. */
181
+ export function runRepoAdd(opts) {
182
+ const hqRoot = path.resolve(opts.hqRoot);
183
+ const manifestPath = path.join(hqRoot, MANIFEST_RELATIVE_PATH);
184
+ if (!fs.existsSync(manifestPath)) {
185
+ throw new Error(`No companies/manifest.yaml under ${hqRoot}. Pass --hq-root <path>.`);
186
+ }
187
+ const source = path.resolve(opts.source);
188
+ if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) {
189
+ throw new Error(`${source} is not a directory.`);
190
+ }
191
+ if (!isGitCheckout(source)) {
192
+ throw new Error(`${source} is not a git checkout (no .git).`);
193
+ }
194
+ const sourceReal = fs.realpathSync(source);
195
+ const hqRootReal = fs.realpathSync(hqRoot);
196
+ const reposRoot = fs.existsSync(path.join(hqRoot, "repos"))
197
+ ? fs.realpathSync(path.join(hqRoot, "repos"))
198
+ : path.join(hqRootReal, "repos");
199
+ // Direct child of repos/private or repos/public, checked on the path as
200
+ // given (parent resolved, last component not), so an existing link such as
201
+ // repos/private/foo -> ~/code/bar is registered as itself, not re-linked.
202
+ const directChild = (p) => {
203
+ const rel = path.relative(reposRoot, p);
204
+ const parts = rel.split(path.sep);
205
+ if (!rel.startsWith("..") &&
206
+ !path.isAbsolute(rel) &&
207
+ parts.length === 2 &&
208
+ (parts[0] === "private" || parts[0] === "public")) {
209
+ return parts;
210
+ }
211
+ return null;
212
+ };
213
+ const sourceAsGiven = path.join(fs.realpathSync(path.dirname(source)), path.basename(source));
214
+ const inPlace = directChild(sourceAsGiven) ?? directChild(sourceReal);
215
+ if (!inPlace && isInside(hqRootReal, sourceReal)) {
216
+ throw new Error(`${source} is inside the HQ root. Only a checkout directly under repos/private or repos/public can be registered from inside HQ.`);
217
+ }
218
+ const warnings = [];
219
+ let entry;
220
+ let linkPath = null;
221
+ if (inPlace) {
222
+ entry = ["repos", ...inPlace].join("/");
223
+ if (opts.name !== undefined) {
224
+ warnings.push(`--name is ignored: ${entry} is already under repos/ and is registered in place.`);
225
+ }
226
+ if (opts.visibility === "public" && inPlace[0] !== "public") {
227
+ warnings.push(`--public is ignored: ${entry} is already under repos/ and is registered in place.`);
228
+ }
229
+ }
230
+ else {
231
+ const name = opts.name ?? path.basename(sourceReal);
232
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === "." || name === "..") {
233
+ throw new Error(`'${name}' is not a usable repo name. Pass --name <name>.`);
234
+ }
235
+ entry = `repos/${opts.visibility}/${name}`;
236
+ linkPath = path.join(hqRoot, "repos", opts.visibility, name);
237
+ if (fs.existsSync(linkPath) || isDanglingSymlink(linkPath)) {
238
+ const existingReal = safeRealpath(linkPath);
239
+ if (existingReal !== sourceReal) {
240
+ throw new Error(`${entry} already exists and points somewhere else. Pass --name <name> to pick another name.`);
241
+ }
242
+ linkPath = null; // already linked to this checkout
243
+ }
244
+ }
245
+ const manifestText = fs.readFileSync(manifestPath, "utf8");
246
+ const nextText = addRepoToManifestText(manifestText, opts.company, entry);
247
+ const manifestChanged = nextText !== manifestText;
248
+ // Refuse when another company already lists this checkout. Consumers treat a
249
+ // repo claimed by two companies as ambiguous, so a second claim would
250
+ // silently drop the first company's ownership.
251
+ const parsed = parseManifest(manifestText);
252
+ const sameCheckout = new Set([entry]);
253
+ for (const block of Object.values(parsed.companies ?? {})) {
254
+ const repos = block?.repos;
255
+ if (!Array.isArray(repos))
256
+ continue;
257
+ for (const r of repos.map(String)) {
258
+ if (safeRealpath(path.resolve(hqRoot, r)) === sourceReal)
259
+ sameCheckout.add(r);
260
+ }
261
+ }
262
+ const claimants = otherCompaniesClaiming(manifestText, opts.company, [...sameCheckout]);
263
+ if (claimants.length > 0) {
264
+ throw new Error(`${entry} is already listed for ${claimants.join(", ")} in companies/manifest.yaml. A repo can belong to one company; remove it there first.`);
265
+ }
266
+ if (!opts.dryRun) {
267
+ if (linkPath) {
268
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
269
+ fs.symlinkSync(sourceReal, linkPath, "dir");
270
+ }
271
+ if (manifestChanged)
272
+ writeManifestAtomically(manifestPath, manifestText, nextText);
273
+ }
274
+ return { entry, linked: linkPath !== null, manifestChanged, warnings };
275
+ }
276
+ function isDanglingSymlink(p) {
277
+ try {
278
+ return fs.lstatSync(p).isSymbolicLink();
279
+ }
280
+ catch {
281
+ return false;
282
+ }
283
+ }
284
+ function safeRealpath(p) {
285
+ try {
286
+ return fs.realpathSync(p);
287
+ }
288
+ catch {
289
+ return null;
290
+ }
291
+ }
292
+ export function registerRepoCommand(program) {
293
+ const repo = program
294
+ .command("repo")
295
+ .description("Register code repos with HQ (repos/ and companies/manifest.yaml)");
296
+ repo
297
+ .command("add <path>")
298
+ .description("Link a git checkout into repos/<private|public>/<name> and list it under " +
299
+ "companies.<slug>.repos in companies/manifest.yaml. A checkout already " +
300
+ "directly under repos/private or repos/public is registered in place.")
301
+ .requiredOption("--company <slug>", "Company that owns the repo (checked against companies/manifest.yaml only, not the session's locked company set or your memberships)")
302
+ .option("--public", "Link under repos/public (default: repos/private)")
303
+ .option("--name <name>", "Name under repos/ (default: the checkout's folder name)")
304
+ .option("--hq-root <path>", "HQ root (default: found from the current directory)")
305
+ .option("--dry-run", "Print what would change without changing anything")
306
+ .action((source, opts) => {
307
+ try {
308
+ const result = runRepoAdd({
309
+ source,
310
+ company: opts.company,
311
+ hqRoot: opts.hqRoot ?? findHqRoot(),
312
+ visibility: opts.public ? "public" : "private",
313
+ name: opts.name,
314
+ dryRun: opts.dryRun,
315
+ });
316
+ for (const w of result.warnings)
317
+ console.warn(chalk.yellow("Warning:"), w);
318
+ const prefix = opts.dryRun ? "Would register" : "Registered";
319
+ const parts = [
320
+ result.linked ? "linked" : "already linked",
321
+ result.manifestChanged ? "added to the manifest" : "already in the manifest",
322
+ ];
323
+ console.log(chalk.green(`${prefix} ${result.entry} for ${opts.company} (${parts.join(", ")}).`));
324
+ }
325
+ catch (err) {
326
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
327
+ process.exit(1);
328
+ }
329
+ });
330
+ }
331
+ //# sourceMappingURL=repo.js.map
@@ -6,7 +6,9 @@
6
6
  * hq scan secrets --json findings as JSON
7
7
  *
8
8
  * Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
9
- * HQ's own token patterns. No network, no account, no quota.
9
+ * HQ's own token patterns. No network, no account, no quota. A staged scan also
10
+ * honours `[[allowlists]]` / `[allowlist]` in the repo's own .gitleaks.toml
11
+ * (lib/secret-scan/repo-config.ts); a file it cannot accept exits 2.
10
12
  *
11
13
  * Exit codes: 0 clean, 1 possible secret found, 2 the scan could not run.
12
14
  * A pre-commit hook blocks on any non-zero exit, so the gate fails closed.
@@ -6,12 +6,15 @@
6
6
  * hq scan secrets --json findings as JSON
7
7
  *
8
8
  * Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
9
- * HQ's own token patterns. No network, no account, no quota.
9
+ * HQ's own token patterns. No network, no account, no quota. A staged scan also
10
+ * honours `[[allowlists]]` / `[allowlist]` in the repo's own .gitleaks.toml
11
+ * (lib/secret-scan/repo-config.ts); a file it cannot accept exits 2.
10
12
  *
11
13
  * Exit codes: 0 clean, 1 possible secret found, 2 the scan could not run.
12
14
  * A pre-commit hook blocks on any non-zero exit, so the gate fails closed.
13
15
  * Output names the rule, file and line; secret values are never printed.
14
16
  */
17
+ import { ScanConfigError } from "../lib/secret-scan/repo-config.js";
15
18
  import { ScanInputError, scanFiles, scanStaged } from "../lib/secret-scan/staged.js";
16
19
  function report(findings, scope) {
17
20
  if (!findings.length)
@@ -22,7 +25,8 @@ function report(findings, scope) {
22
25
  process.stderr.write(` ${f.file}:${f.line} ${f.ruleId} ${f.description}\n`);
23
26
  }
24
27
  process.stderr.write("\nRemove the secret from the change, then stage it again. Keep real credentials in the HQ vault\n" +
25
- "(hq secrets, hq run). A known false positive can carry a `gitleaks:allow` comment on that line.\n");
28
+ "(hq secrets, hq run). A known false positive can carry a `gitleaks:allow` comment on that line,\n" +
29
+ "or an [[allowlists]] entry in the repo's .gitleaks.toml.\n");
26
30
  }
27
31
  export function runScanSecrets(paths, opts, cwd = process.cwd()) {
28
32
  let findings;
@@ -30,7 +34,7 @@ export function runScanSecrets(paths, opts, cwd = process.cwd()) {
30
34
  findings = paths.length && !opts.staged ? scanFiles(paths) : scanStaged(cwd);
31
35
  }
32
36
  catch (err) {
33
- const msg = err instanceof ScanInputError ? err.message : `scan failed: ${err instanceof Error ? err.message : String(err)}`;
37
+ const msg = err instanceof ScanInputError || err instanceof ScanConfigError ? err.message : `scan failed: ${err instanceof Error ? err.message : String(err)}`;
34
38
  if (opts.json)
35
39
  process.stdout.write(`${JSON.stringify({ ok: false, error: msg })}\n`);
36
40
  process.stderr.write(`HQ secret scan: ${msg}\n`);