@indigoai-us/hq-cli 5.71.0 → 5.73.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.
Files changed (116) hide show
  1. package/dist/bin/hq-auth-refresh.js +1 -4
  2. package/dist/cli-version.js +1 -4
  3. package/dist/commands/__fixtures__/make-tar.js +1 -4
  4. package/dist/commands/_patterns.js +1 -4
  5. package/dist/commands/add.js +1 -4
  6. package/dist/commands/agents.js +1 -4
  7. package/dist/commands/api-keys.js +1 -4
  8. package/dist/commands/auth.js +1 -4
  9. package/dist/commands/billing.js +1 -4
  10. package/dist/commands/channels.js +1 -4
  11. package/dist/commands/cloud-demote.js +1 -4
  12. package/dist/commands/cloud-provision.js +1 -4
  13. package/dist/commands/cloud.js +1 -4
  14. package/dist/commands/company.js +1 -4
  15. package/dist/commands/creators.js +1 -4
  16. package/dist/commands/crm.js +1 -4
  17. package/dist/commands/db-migrate.js +1 -4
  18. package/dist/commands/db-provision.js +1 -4
  19. package/dist/commands/db-sql.js +1 -4
  20. package/dist/commands/db-status.js +1 -4
  21. package/dist/commands/db.js +1 -4
  22. package/dist/commands/dm.js +1 -4
  23. package/dist/commands/feedback.js +1 -4
  24. package/dist/commands/files-browse.js +1 -4
  25. package/dist/commands/files.js +1 -4
  26. package/dist/commands/group-grants.js +1 -4
  27. package/dist/commands/groups.js +1 -4
  28. package/dist/commands/integrations.js +1 -4
  29. package/dist/commands/list.js +1 -4
  30. package/dist/commands/login.js +1 -4
  31. package/dist/commands/logout.js +1 -4
  32. package/dist/commands/mcp-registration.js +1 -4
  33. package/dist/commands/mcp-status.js +1 -4
  34. package/dist/commands/meetings.js +1 -4
  35. package/dist/commands/members.js +1 -4
  36. package/dist/commands/onboard-identity-guard.js +1 -4
  37. package/dist/commands/onboard-join.js +1 -4
  38. package/dist/commands/onboard-warning.js +1 -4
  39. package/dist/commands/onboard.js +1 -4
  40. package/dist/commands/outposts.d.ts +30 -1
  41. package/dist/commands/outposts.js +221 -10
  42. package/dist/commands/pack-install.js +1 -4
  43. package/dist/commands/packs.js +1 -4
  44. package/dist/commands/people.js +1 -4
  45. package/dist/commands/pkg-install.js +1 -4
  46. package/dist/commands/pkg-list.js +1 -4
  47. package/dist/commands/pkg-remove.js +1 -4
  48. package/dist/commands/pkg-update.js +1 -4
  49. package/dist/commands/publish.js +1 -4
  50. package/dist/commands/reindex.js +1 -4
  51. package/dist/commands/rescue.js +1 -4
  52. package/dist/commands/run.js +1 -4
  53. package/dist/commands/safe-extract.js +1 -4
  54. package/dist/commands/secrets-scope.js +1 -4
  55. package/dist/commands/secrets.js +1 -4
  56. package/dist/commands/signals.js +1 -4
  57. package/dist/commands/skill.js +1 -4
  58. package/dist/commands/sources.js +1 -4
  59. package/dist/commands/sync-mode.js +1 -4
  60. package/dist/commands/sync-narrow.js +1 -4
  61. package/dist/commands/sync.js +1 -4
  62. package/dist/commands/team-sync.js +1 -4
  63. package/dist/commands/update.js +1 -4
  64. package/dist/commands/whoami.js +1 -4
  65. package/dist/commands/workers.js +1 -4
  66. package/dist/index.js +1 -4
  67. package/dist/lib/db/control-plane.js +1 -4
  68. package/dist/lib/db/local.js +1 -4
  69. package/dist/lib/db/migrate.js +1 -4
  70. package/dist/lib/db/paths.js +1 -4
  71. package/dist/lib/db/remote-engine.js +1 -4
  72. package/dist/lib/db/remote-sql.js +1 -4
  73. package/dist/lib/db/sql.js +1 -4
  74. package/dist/lib/local-tree-diff.js +1 -4
  75. package/dist/lib/narrow-hint-banner.js +1 -4
  76. package/dist/main.js +1 -4
  77. package/dist/node-preflight.js +1 -4
  78. package/dist/run/discover-schemas.js +1 -4
  79. package/dist/run/hq-plugin.js +1 -4
  80. package/dist/sentry-before-send.js +1 -4
  81. package/dist/sentry-dsn.generated.js +1 -4
  82. package/dist/sentry.js +1 -4
  83. package/dist/strategies/link.js +1 -4
  84. package/dist/strategies/merge.js +1 -4
  85. package/dist/types.js +1 -4
  86. package/dist/utils/billing-gate.js +1 -4
  87. package/dist/utils/breadcrumb-buffer.js +1 -4
  88. package/dist/utils/cli-telemetry.js +1 -4
  89. package/dist/utils/cognito-session.js +1 -4
  90. package/dist/utils/contribution-table.js +1 -4
  91. package/dist/utils/environmental-error.js +1 -4
  92. package/dist/utils/epipe.js +1 -4
  93. package/dist/utils/feedback-diagnostics.js +1 -4
  94. package/dist/utils/feedback-screenshots.js +1 -4
  95. package/dist/utils/feedback-versions.js +1 -4
  96. package/dist/utils/git.js +1 -4
  97. package/dist/utils/integrity.js +1 -4
  98. package/dist/utils/intercepted-process-exit.js +1 -4
  99. package/dist/utils/login-provider.js +1 -4
  100. package/dist/utils/manifest.js +1 -4
  101. package/dist/utils/pack-contributions.js +1 -4
  102. package/dist/utils/people.js +1 -4
  103. package/dist/utils/registry-client.js +1 -4
  104. package/dist/utils/registry.js +1 -4
  105. package/dist/utils/sandbox-runner-client.js +1 -4
  106. package/dist/utils/secrets-cache.js +1 -4
  107. package/dist/utils/sentry-identity.js +1 -4
  108. package/dist/utils/vault-api.js +1 -4
  109. package/dist/utils/version-check.js +1 -4
  110. package/dist/utils/version-gate.js +1 -4
  111. package/package.json +3 -2
  112. package/scripts/chmod-bins.mjs +49 -0
  113. package/src/commands/outposts-replica-sync.test.ts +272 -0
  114. package/src/commands/outposts.test.ts +32 -0
  115. package/src/commands/outposts.ts +330 -7
  116. package/src/packaging.test.ts +55 -0
@@ -29,6 +29,7 @@ import * as os from "node:os";
29
29
  import * as path from "node:path";
30
30
  import * as readline from "node:readline";
31
31
  import { randomBytes } from "node:crypto";
32
+ import * as yaml from "js-yaml";
32
33
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
33
34
  import { ensureCognitoToken } from "../utils/cognito-session.js";
34
35
  import { vaultApiFetch } from "../utils/vault-api.js";
@@ -181,6 +182,26 @@ export async function regenerateLoginUrl(
181
182
  });
182
183
  }
183
184
 
185
+ /**
186
+ * Hand the box the one-time Claude sign-in code the operator got from the login
187
+ * URL. hq-pro stores it as the row's pending code; the box polls for it, feeds
188
+ * it to `claude`, and flips itself `awaiting-claude-login → ready`. This is the
189
+ * terminal-native equivalent of pasting the code into the web console.
190
+ */
191
+ export async function submitLoginCode(
192
+ token: string,
193
+ code: string,
194
+ outpostId?: string,
195
+ ): Promise<Record<string, unknown>> {
196
+ return outpostRequest({
197
+ token,
198
+ path: "/outpost/login-code",
199
+ method: "POST",
200
+ body: { code },
201
+ query: outpostId ? { outpostId } : undefined,
202
+ });
203
+ }
204
+
184
205
  export async function destroyOutpost(
185
206
  token: string,
186
207
  outpostId?: string,
@@ -795,12 +816,246 @@ async function selfDeployOutpost(
795
816
  console.log(chalk.dim("It is unregistered and unmanaged by hq-pro (no console entry or remote management)."));
796
817
  }
797
818
 
819
+ // ---------------------------------------------------------------------------
820
+ // Hidden replica-sync command (box-side full-replica timer target)
821
+ // ---------------------------------------------------------------------------
822
+ //
823
+ // A MANAGED outpost (provisioned via `hq outposts provision`) runs this on a
824
+ // systemd timer to make the box a full replica of the operator's HQ:
825
+ // 1. hq auth refresh — freshen the seeded Cognito session
826
+ // 2. hq sync pull --personal — the personal vault (repos.yaml, policies, …)
827
+ // 3. hq rescue — re-lay the core kernel (needs companies/)
828
+ // 4. clone repos.yaml — the operator's OWN repos, gh-authed via vault
829
+ //
830
+ // Unlike `self-deploy` (which is a manual, unmanaged, all-company watcher on a
831
+ // box you own), replica-sync is GENERIC: it works for every HQ user off their
832
+ // own personal vault — no per-operator setup script. It is deliberately
833
+ // best-effort end to end: a repeating timer must never leave the systemd unit
834
+ // in `failed` just because one transient step (a mirror blip, a not-yet-signed
835
+ // box) didn't complete this cycle.
836
+
837
+ /**
838
+ * The extra local surface `replica-sync` needs on top of the self-deploy set:
839
+ * two filesystem probes for the repos clone. Kept as an extension so the
840
+ * `self-deploy` dependency surface stays minimal.
841
+ */
842
+ export interface ReplicaSyncDependencies extends SelfDeployDependencies {
843
+ pathExists: (p: string) => boolean;
844
+ mkdirp: (p: string) => void;
845
+ }
846
+
847
+ const defaultReplicaSyncDependencies: ReplicaSyncDependencies = {
848
+ ...defaultSelfDeployDependencies,
849
+ pathExists: (p) => fs.existsSync(p),
850
+ mkdirp: (p) => {
851
+ fs.mkdirSync(p, { recursive: true });
852
+ },
853
+ };
854
+
855
+ /** One repo entry resolved from `personal/data/repos.yaml`. */
856
+ export interface ReplicaRepo {
857
+ url: string;
858
+ visibility: "public" | "private";
859
+ name: string;
860
+ }
861
+
862
+ function repoNameFromUrl(url: string): string {
863
+ const tail = url.split("/").pop() ?? "";
864
+ return tail.replace(/\.git$/, "").trim();
865
+ }
866
+
867
+ /**
868
+ * A repo `name` becomes a path segment under repos/<visibility>/. A synced
869
+ * repos.yaml is operator data, but a malformed or hostile name must never
870
+ * escape that directory, so constrain it to a single, non-traversal component.
871
+ */
872
+ function isSafePathComponent(value: string): boolean {
873
+ return (
874
+ value.length > 0 &&
875
+ !value.includes("/") &&
876
+ !value.includes("\\") &&
877
+ value !== "." &&
878
+ value !== ".."
879
+ );
880
+ }
881
+
882
+ /**
883
+ * Parse `personal/data/repos.yaml` into a clone list. Pure + exported for
884
+ * tests. Mirrors setup.sh's `clone_repos`: `visibility` defaults to public,
885
+ * `name` defaults to the URL basename. Malformed entries are skipped, never
886
+ * thrown.
887
+ */
888
+ export function parseReposManifest(yamlText: string): ReplicaRepo[] {
889
+ let doc: unknown;
890
+ try {
891
+ doc = yaml.load(yamlText);
892
+ } catch {
893
+ return [];
894
+ }
895
+ const rawRepos = (doc as { repos?: unknown } | null)?.repos;
896
+ if (!Array.isArray(rawRepos)) return [];
897
+
898
+ const out: ReplicaRepo[] = [];
899
+ for (const entry of rawRepos) {
900
+ if (!entry || typeof entry !== "object") continue;
901
+ const e = entry as Record<string, unknown>;
902
+ const url = typeof e.url === "string" ? e.url.trim() : "";
903
+ if (!url) continue;
904
+ const visibility = e.visibility === "private" ? "private" : "public";
905
+ const name =
906
+ typeof e.name === "string" && e.name.trim()
907
+ ? e.name.trim()
908
+ : repoNameFromUrl(url);
909
+ if (!isSafePathComponent(name)) continue;
910
+ out.push({ url, visibility, name });
911
+ }
912
+ return out;
913
+ }
914
+
915
+ /**
916
+ * Best-effort spawn: log a one-line outcome and carry on. NEVER throws and
917
+ * NEVER echoes child output — the same secret-safety stance as `runChecked`,
918
+ * since these children (auth refresh, secrets exec) touch credentials.
919
+ */
920
+ function runBestEffort(
921
+ deps: ReplicaSyncDependencies,
922
+ command: string,
923
+ args: string[],
924
+ description: string,
925
+ options?: Parameters<typeof spawnSync>[2],
926
+ ): boolean {
927
+ let result: ReturnType<typeof spawnSync>;
928
+ try {
929
+ result = deps.spawnSync(command, args, options ?? { stdio: "inherit" });
930
+ } catch {
931
+ console.warn(`replica-sync: ${description} could not run — continuing.`);
932
+ return false;
933
+ }
934
+ if (!result || result.error || result.status !== 0) {
935
+ console.warn(`replica-sync: ${description} did not complete cleanly — continuing.`);
936
+ return false;
937
+ }
938
+ return true;
939
+ }
940
+
941
+ /**
942
+ * Authenticate `gh` for private clones by streaming GITHUB_TOKEN through the
943
+ * vault into the child's ENV ONLY, unsetting it in-shell, and piping it to
944
+ * `gh auth login --with-token` over stdin. The token never touches argv or
945
+ * this process's logs. Mirrors personal/scripts/setup.sh's `configure_gh`.
946
+ */
947
+ function authGitHubViaVault(deps: ReplicaSyncDependencies): void {
948
+ const ghLogin = [
949
+ 'TOKEN="$GITHUB_TOKEN"',
950
+ "unset GITHUB_TOKEN",
951
+ 'printf "%s" "$TOKEN" | gh auth login --with-token',
952
+ ].join("\n");
953
+ const authed = runBestEffort(
954
+ deps,
955
+ "hq",
956
+ ["secrets", "--personal", "exec", "--only", "GITHUB_TOKEN", "--", "bash", "-c", ghLogin],
957
+ "GitHub auth via vault",
958
+ );
959
+ if (authed) {
960
+ // Let plain `git clone https://github.com/...` reuse gh's token so private
961
+ // repos don't prompt for a username/password.
962
+ runBestEffort(deps, "gh", ["auth", "setup-git"], "gh auth setup-git");
963
+ }
964
+ }
965
+
966
+ /** Clone any repos from `personal/data/repos.yaml` that aren't on disk yet. */
967
+ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
968
+ const manifestPath = path.join(hqRoot, "personal", "data", "repos.yaml");
969
+ let manifestText: string;
970
+ try {
971
+ manifestText = deps.readTextFile(manifestPath);
972
+ } catch {
973
+ console.log("replica-sync: no personal/data/repos.yaml — skipping repo clone.");
974
+ return;
975
+ }
976
+
977
+ const repos = parseReposManifest(manifestText);
978
+ if (repos.length === 0) {
979
+ console.log("replica-sync: repos.yaml lists no repos — skipping.");
980
+ return;
981
+ }
982
+
983
+ const missing = repos.filter(
984
+ (repo) =>
985
+ !deps.pathExists(path.join(hqRoot, "repos", repo.visibility, repo.name, ".git")),
986
+ );
987
+ if (missing.length === 0) {
988
+ console.log("replica-sync: all repos already present.");
989
+ return;
990
+ }
991
+
992
+ // Private repos need gh auth first; public-only manifests skip the vault call.
993
+ if (missing.some((repo) => repo.visibility === "private")) {
994
+ authGitHubViaVault(deps);
995
+ }
996
+
997
+ for (const repo of missing) {
998
+ deps.mkdirp(path.join(hqRoot, "repos", repo.visibility));
999
+ const dest = path.join(hqRoot, "repos", repo.visibility, repo.name);
1000
+ console.log(`replica-sync: cloning ${repo.visibility}/${repo.name}…`);
1001
+ runBestEffort(
1002
+ deps,
1003
+ "git",
1004
+ ["clone", "--recurse-submodules", repo.url, dest],
1005
+ `clone ${repo.visibility}/${repo.name}`,
1006
+ );
1007
+ }
1008
+ }
1009
+
1010
+ async function replicaSyncOutpost(
1011
+ opts: { hqRoot?: string },
1012
+ deps: ReplicaSyncDependencies,
1013
+ ): Promise<void> {
1014
+ const hqRoot = opts.hqRoot ?? deps.defaultHqRoot();
1015
+
1016
+ // Gate: only replicate on a box that has an HQ session. A box that was never
1017
+ // signed in has nothing to pull — exit cleanly so the timer stays green.
1018
+ const session = deps.loadCachedTokens();
1019
+ if (!session?.refreshToken) {
1020
+ console.log("replica-sync: no HQ session on this box yet — nothing to replicate.");
1021
+ return;
1022
+ }
1023
+
1024
+ // Auth refresh gates the run: if the seeded refresh token is dead, every
1025
+ // authenticated step below (pull, secrets exec) would just cascade 401s.
1026
+ // Stop the cycle cleanly instead — the timer retries next tick.
1027
+ if (!runBestEffort(deps, "hq", ["auth", "refresh"], "HQ auth refresh")) {
1028
+ console.warn(
1029
+ "replica-sync: auth refresh failed — skipping this cycle (token likely expired).",
1030
+ );
1031
+ return;
1032
+ }
1033
+ runBestEffort(
1034
+ deps,
1035
+ "hq",
1036
+ ["sync", "pull", "--personal", "--hq-root", hqRoot, "--on-conflict", "keep"],
1037
+ "personal vault pull",
1038
+ );
1039
+
1040
+ // rescue lays down the core kernel and expects companies/ to exist.
1041
+ deps.mkdirp(path.join(hqRoot, "companies"));
1042
+ runBestEffort(
1043
+ deps,
1044
+ "hq",
1045
+ ["rescue", "--hq-root", hqRoot, "--yes"],
1046
+ "HQ kernel rescue",
1047
+ );
1048
+
1049
+ replicateRepos(deps, hqRoot);
1050
+ console.log("replica-sync: complete.");
1051
+ }
1052
+
798
1053
  export function registerOutpostsCommand(
799
1054
  program: Command,
800
- selfDeployOverrides: Partial<SelfDeployDependencies> = {},
1055
+ selfDeployOverrides: Partial<ReplicaSyncDependencies> = {},
801
1056
  ): void {
802
- const selfDeployDependencies: SelfDeployDependencies = {
803
- ...defaultSelfDeployDependencies,
1057
+ const deps: ReplicaSyncDependencies = {
1058
+ ...defaultReplicaSyncDependencies,
804
1059
  ...selfDeployOverrides,
805
1060
  };
806
1061
  const outposts = program
@@ -811,15 +1066,33 @@ export function registerOutpostsCommand(
811
1066
  .command("self-deploy", { hidden: true })
812
1067
  .description("Configure this EC2 host as a locally self-hosted HQ outpost")
813
1068
  .option("--yes", "Skip the self-hosting confirmation")
814
- .option("--hq-root <path>", "HQ root to sync", selfDeployDependencies.defaultHqRoot())
1069
+ .option("--hq-root <path>", "HQ root to sync", deps.defaultHqRoot())
815
1070
  .action(async (opts: { yes?: boolean; hqRoot?: string }) => {
816
1071
  try {
817
- await selfDeployOutpost(opts, selfDeployDependencies);
1072
+ await selfDeployOutpost(opts, deps);
818
1073
  } catch (err) {
819
1074
  fail(err);
820
1075
  }
821
1076
  });
822
1077
 
1078
+ outposts
1079
+ .command("replica-sync", { hidden: true })
1080
+ .description(
1081
+ "Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.",
1082
+ )
1083
+ .option("--hq-root <path>", "HQ root to replicate into", deps.defaultHqRoot())
1084
+ .action(async (opts: { hqRoot?: string }) => {
1085
+ // Best-effort background job: never hard-fail the systemd unit on a
1086
+ // transient step. Swallow, log, and exit 0.
1087
+ try {
1088
+ await replicaSyncOutpost(opts, deps);
1089
+ } catch (err) {
1090
+ console.warn(
1091
+ `replica-sync: unexpected error — ${err instanceof Error ? err.message : String(err)}`,
1092
+ );
1093
+ }
1094
+ });
1095
+
823
1096
  outposts
824
1097
  .command("provision")
825
1098
  .alias("create")
@@ -1197,8 +1470,58 @@ export function registerOutpostsCommand(
1197
1470
  }
1198
1471
  console.log(
1199
1472
  chalk.green(
1200
- "Login-URL regeneration requested. The box mints a fresh URL shortly" +
1201
- "check `hq outposts status` to pick it up.",
1473
+ "Login-URL regeneration requested. The box mints a fresh URL shortly.",
1474
+ ),
1475
+ );
1476
+ console.log(
1477
+ chalk.dim(
1478
+ "Next: `hq outposts status" +
1479
+ (opts.id ? ` --id ${opts.id}` : "") +
1480
+ "` to read the login URL, open it and sign in, then paste the code back with " +
1481
+ "`hq outposts login-code <code>" +
1482
+ (opts.id ? ` --id ${opts.id}` : "") +
1483
+ "`.",
1484
+ ),
1485
+ );
1486
+ } catch (err) {
1487
+ fail(err);
1488
+ }
1489
+ });
1490
+
1491
+ outposts
1492
+ .command("login-code <code>")
1493
+ .description(
1494
+ "Submit the Claude sign-in code for an Outpost that is awaiting login",
1495
+ )
1496
+ .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1497
+ .option("--json", "Emit raw JSON")
1498
+ .action(async function (
1499
+ this: Command,
1500
+ code: string,
1501
+ opts: { id?: string; json?: boolean },
1502
+ ) {
1503
+ try {
1504
+ const trimmed = code.trim();
1505
+ if (!trimmed) {
1506
+ console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
1507
+ process.exit(1);
1508
+ }
1509
+ const token = await ensureCognitoToken();
1510
+ const result = await submitLoginCode(token, trimmed, opts.id);
1511
+ if (opts.json) {
1512
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1513
+ return;
1514
+ }
1515
+ console.log(
1516
+ chalk.green(
1517
+ "Code submitted — the box will finish signing in shortly.",
1518
+ ),
1519
+ );
1520
+ console.log(
1521
+ chalk.dim(
1522
+ "Track it: `hq outposts status" +
1523
+ (opts.id ? ` --id ${opts.id}` : "") +
1524
+ "` (it flips to `ready` once Claude auth completes).",
1202
1525
  ),
1203
1526
  );
1204
1527
  } catch (err) {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Packaging guard: every declared `bin` must ship EXECUTABLE.
3
+ *
4
+ * Regression pin for the "outpost is not CLI-ready" class of failure: `tsc`
5
+ * emits dist bins as 0644, and a root global install on a fresh outpost has
6
+ * been observed to leave the linked target non-executable, so `/usr/bin/hq`
7
+ * dies with "Permission denied" and the box never becomes usable. The build
8
+ * runs `scripts/chmod-bins.mjs` to 0755 the bins; this test locks that wiring
9
+ * in and, when a build has already produced `dist/`, asserts the bits directly.
10
+ */
11
+ import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { dirname, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
+ const pkg = JSON.parse(
18
+ readFileSync(resolve(repoRoot, "package.json"), "utf8"),
19
+ ) as {
20
+ bin?: Record<string, string> | string;
21
+ scripts?: Record<string, string>;
22
+ };
23
+
24
+ const binMap =
25
+ typeof pkg.bin === "string" ? { hq: pkg.bin } : (pkg.bin ?? {});
26
+
27
+ describe("packaging: bin executable bit", () => {
28
+ it("wires the chmod-bins step into build and prepublishOnly", () => {
29
+ expect(pkg.scripts?.build ?? "").toContain("chmod-bins");
30
+ // prepublishOnly must rebuild so a publish always re-chmods, regardless of
31
+ // the publisher's local dist state.
32
+ expect(pkg.scripts?.prepublishOnly ?? "").toContain("build");
33
+ });
34
+
35
+ it("declares at least one bin, all pointing at built .js files", () => {
36
+ const entries = Object.entries(binMap);
37
+ expect(entries.length).toBeGreaterThan(0);
38
+ for (const [, relPath] of entries) {
39
+ expect(relPath.endsWith(".js")).toBe(true);
40
+ }
41
+ });
42
+
43
+ it("ships every bin executable once dist is built", () => {
44
+ for (const [name, relPath] of Object.entries(binMap)) {
45
+ const target = resolve(repoRoot, relPath);
46
+ // Pre-build (e.g. a bare `vitest` before `tsc`) → nothing to assert yet.
47
+ if (!existsSync(target)) continue;
48
+ const mode = statSync(target).mode;
49
+ // Owner-executable bit is the one that matters for `npm` bin linking.
50
+ expect(mode & 0o100, `${name} (${relPath}) must be executable`).toBe(
51
+ 0o100,
52
+ );
53
+ }
54
+ });
55
+ });