@indigoai-us/hq-cli 5.77.14 → 5.79.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 (309) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/meetings.js +16 -9
  31. package/dist/commands/members.js +16 -5
  32. package/dist/commands/pack-install.d.ts +14 -0
  33. package/dist/commands/pack-install.js +42 -2
  34. package/dist/commands/pkg-install.js +39 -2
  35. package/dist/commands/run.js +6 -2
  36. package/dist/commands/secrets.js +120 -65
  37. package/dist/main.js +6 -0
  38. package/dist/utils/api-key-command-gate.d.ts +45 -0
  39. package/dist/utils/api-key-command-gate.js +51 -0
  40. package/dist/utils/hq-roots.d.ts +91 -0
  41. package/dist/utils/hq-roots.js +212 -0
  42. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  43. package/dist/utils/resolve-vault-credential.js +152 -6
  44. package/dist/utils/run-bundled-script.d.ts +66 -0
  45. package/dist/utils/run-bundled-script.js +80 -0
  46. package/dist/utils/vault-api.d.ts +21 -0
  47. package/dist/utils/vault-api.js +74 -3
  48. package/package.json +7 -1
  49. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  50. package/.github/workflows/ci.yml +0 -46
  51. package/.github/workflows/publish.yml +0 -36
  52. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  53. package/e2e/cli.test.ts +0 -128
  54. package/eslint.config.js +0 -37
  55. package/pnpm-workspace.yaml +0 -4
  56. package/scripts/chmod-bins.mjs +0 -49
  57. package/scripts/generate-dsn.mjs +0 -26
  58. package/scripts/generate-scan-packages-table.mjs +0 -113
  59. package/scripts/smoke-sources-signals.sh +0 -103
  60. package/src/bin/hq-auth-refresh.ts +0 -43
  61. package/src/cli-version.ts +0 -13
  62. package/src/commands/__fixtures__/make-tar.ts +0 -126
  63. package/src/commands/_patterns.ts +0 -10
  64. package/src/commands/add.ts +0 -75
  65. package/src/commands/agents.test.ts +0 -527
  66. package/src/commands/agents.ts +0 -923
  67. package/src/commands/api-keys.test.ts +0 -291
  68. package/src/commands/api-keys.ts +0 -382
  69. package/src/commands/artifact-verify.test.ts +0 -177
  70. package/src/commands/auth.test.ts +0 -82
  71. package/src/commands/auth.ts +0 -213
  72. package/src/commands/billing.test.ts +0 -158
  73. package/src/commands/billing.ts +0 -146
  74. package/src/commands/channels.test.ts +0 -74
  75. package/src/commands/channels.ts +0 -91
  76. package/src/commands/cloud-demote.test.ts +0 -401
  77. package/src/commands/cloud-demote.ts +0 -278
  78. package/src/commands/cloud-provision.test.ts +0 -1117
  79. package/src/commands/cloud-provision.ts +0 -942
  80. package/src/commands/cloud.pull-all.test.ts +0 -711
  81. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  82. package/src/commands/cloud.push-all.test.ts +0 -485
  83. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  84. package/src/commands/cloud.selectors.test.ts +0 -208
  85. package/src/commands/cloud.status.test.ts +0 -160
  86. package/src/commands/cloud.test.ts +0 -41
  87. package/src/commands/cloud.ts +0 -2130
  88. package/src/commands/company.test.ts +0 -202
  89. package/src/commands/company.ts +0 -148
  90. package/src/commands/creators.test.ts +0 -60
  91. package/src/commands/creators.ts +0 -117
  92. package/src/commands/crm.test.ts +0 -179
  93. package/src/commands/crm.ts +0 -236
  94. package/src/commands/db-migrate.ts +0 -55
  95. package/src/commands/db-provision.ts +0 -114
  96. package/src/commands/db-sql.ts +0 -124
  97. package/src/commands/db-status.ts +0 -108
  98. package/src/commands/db.ts +0 -26
  99. package/src/commands/dm.test.ts +0 -748
  100. package/src/commands/dm.ts +0 -1226
  101. package/src/commands/feedback.test.ts +0 -413
  102. package/src/commands/feedback.ts +0 -169
  103. package/src/commands/files-browse.test.ts +0 -1249
  104. package/src/commands/files-browse.ts +0 -1506
  105. package/src/commands/files-delete.test.ts +0 -484
  106. package/src/commands/files-recovery.test.ts +0 -370
  107. package/src/commands/files.test.ts +0 -722
  108. package/src/commands/files.ts +0 -1446
  109. package/src/commands/group-grants.test.ts +0 -330
  110. package/src/commands/group-grants.ts +0 -455
  111. package/src/commands/groups.test.ts +0 -44
  112. package/src/commands/groups.ts +0 -368
  113. package/src/commands/integrations.test.ts +0 -633
  114. package/src/commands/integrations.ts +0 -507
  115. package/src/commands/list.ts +0 -85
  116. package/src/commands/login.ts +0 -52
  117. package/src/commands/logout.ts +0 -30
  118. package/src/commands/marketplace-install.test.ts +0 -543
  119. package/src/commands/marketplace-security.test.ts +0 -646
  120. package/src/commands/mcp-registration.test.ts +0 -2787
  121. package/src/commands/mcp-registration.ts +0 -2612
  122. package/src/commands/mcp-status.test.ts +0 -483
  123. package/src/commands/mcp-status.ts +0 -575
  124. package/src/commands/mcp-status.us011.test.ts +0 -243
  125. package/src/commands/meetings.test.ts +0 -522
  126. package/src/commands/meetings.ts +0 -789
  127. package/src/commands/members.test.ts +0 -1474
  128. package/src/commands/members.ts +0 -1181
  129. package/src/commands/onboard-identity-guard.test.ts +0 -99
  130. package/src/commands/onboard-identity-guard.ts +0 -103
  131. package/src/commands/onboard-join.test.ts +0 -96
  132. package/src/commands/onboard-join.ts +0 -101
  133. package/src/commands/onboard-warning.test.ts +0 -26
  134. package/src/commands/onboard-warning.ts +0 -12
  135. package/src/commands/onboard.ts +0 -267
  136. package/src/commands/outposts-heartbeat.test.ts +0 -299
  137. package/src/commands/outposts-heartbeat.ts +0 -310
  138. package/src/commands/outposts-replica-sync.test.ts +0 -272
  139. package/src/commands/outposts-self-deploy.test.ts +0 -243
  140. package/src/commands/outposts.test.ts +0 -1039
  141. package/src/commands/outposts.ts +0 -1935
  142. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  143. package/src/commands/pack-install.test.ts +0 -1469
  144. package/src/commands/pack-install.ts +0 -2256
  145. package/src/commands/pack-update-cache.test.ts +0 -149
  146. package/src/commands/packs-update-api-key.test.ts +0 -105
  147. package/src/commands/packs.test.ts +0 -88
  148. package/src/commands/packs.ts +0 -622
  149. package/src/commands/people.test.ts +0 -807
  150. package/src/commands/people.ts +0 -422
  151. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  152. package/src/commands/pkg-install.ts +0 -221
  153. package/src/commands/pkg-list.test.ts +0 -79
  154. package/src/commands/pkg-list.ts +0 -13
  155. package/src/commands/pkg-remove.ts +0 -71
  156. package/src/commands/pkg-update.ts +0 -189
  157. package/src/commands/publish.test.ts +0 -650
  158. package/src/commands/publish.ts +0 -580
  159. package/src/commands/reindex.test.ts +0 -259
  160. package/src/commands/reindex.ts +0 -274
  161. package/src/commands/rescue.test.ts +0 -39
  162. package/src/commands/rescue.ts +0 -210
  163. package/src/commands/run.env-local.test.ts +0 -88
  164. package/src/commands/run.test.ts +0 -45
  165. package/src/commands/run.ts +0 -190
  166. package/src/commands/safe-extract.test.ts +0 -459
  167. package/src/commands/safe-extract.ts +0 -444
  168. package/src/commands/secrets-scope.test.ts +0 -56
  169. package/src/commands/secrets-scope.ts +0 -32
  170. package/src/commands/secrets.parse-destination.test.ts +0 -38
  171. package/src/commands/secrets.test.ts +0 -2050
  172. package/src/commands/secrets.ts +0 -2249
  173. package/src/commands/signals.ts +0 -351
  174. package/src/commands/skill.test.ts +0 -770
  175. package/src/commands/skill.ts +0 -796
  176. package/src/commands/sources.ts +0 -362
  177. package/src/commands/sync-mode.test.ts +0 -366
  178. package/src/commands/sync-mode.ts +0 -387
  179. package/src/commands/sync-narrow.test.ts +0 -633
  180. package/src/commands/sync-narrow.ts +0 -555
  181. package/src/commands/sync.ts +0 -165
  182. package/src/commands/team-sync.ts +0 -628
  183. package/src/commands/update.ts +0 -71
  184. package/src/commands/whoami.ts +0 -83
  185. package/src/commands/workers.test.ts +0 -158
  186. package/src/commands/workers.ts +0 -301
  187. package/src/index.test.ts +0 -32
  188. package/src/index.ts +0 -20
  189. package/src/lib/db/control-plane.test.ts +0 -59
  190. package/src/lib/db/control-plane.ts +0 -113
  191. package/src/lib/db/local.test.ts +0 -81
  192. package/src/lib/db/local.ts +0 -148
  193. package/src/lib/db/migrate.test.ts +0 -133
  194. package/src/lib/db/migrate.ts +0 -137
  195. package/src/lib/db/paths.test.ts +0 -112
  196. package/src/lib/db/paths.ts +0 -128
  197. package/src/lib/db/remote-engine.test.ts +0 -44
  198. package/src/lib/db/remote-engine.ts +0 -148
  199. package/src/lib/db/remote-sql.test.ts +0 -32
  200. package/src/lib/db/remote-sql.ts +0 -62
  201. package/src/lib/db/sql.test.ts +0 -106
  202. package/src/lib/db/sql.ts +0 -192
  203. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  204. package/src/lib/local-tree-diff.test.ts +0 -301
  205. package/src/lib/local-tree-diff.ts +0 -362
  206. package/src/lib/narrow-hint-banner.test.ts +0 -235
  207. package/src/lib/narrow-hint-banner.ts +0 -212
  208. package/src/lib/plan-limit-nag.test.ts +0 -317
  209. package/src/lib/plan-limit-nag.ts +0 -264
  210. package/src/main.ts +0 -371
  211. package/src/node-preflight.test.ts +0 -60
  212. package/src/node-preflight.ts +0 -67
  213. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  214. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  215. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  216. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  217. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  218. package/src/outpost/session-heartbeat.test.ts +0 -459
  219. package/src/outpost/session-heartbeat.ts +0 -877
  220. package/src/packaging.test.ts +0 -109
  221. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  222. package/src/run/discover-schemas.test.ts +0 -153
  223. package/src/run/discover-schemas.ts +0 -79
  224. package/src/run/hq-plugin.test.ts +0 -371
  225. package/src/run/hq-plugin.ts +0 -277
  226. package/src/run/varlock-shape.test.ts +0 -57
  227. package/src/schemas/hq-package.schema.json +0 -155
  228. package/src/sentry-before-send.test.ts +0 -359
  229. package/src/sentry-before-send.ts +0 -221
  230. package/src/sentry-epipe.test.ts +0 -37
  231. package/src/sentry-release.test.ts +0 -54
  232. package/src/sentry.test.ts +0 -24
  233. package/src/sentry.ts +0 -54
  234. package/src/strategies/link.ts +0 -62
  235. package/src/strategies/merge.ts +0 -142
  236. package/src/types.ts +0 -146
  237. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  238. package/src/utils/auth-error.test.ts +0 -40
  239. package/src/utils/auth-error.ts +0 -42
  240. package/src/utils/billing-gate.test.ts +0 -95
  241. package/src/utils/billing-gate.ts +0 -228
  242. package/src/utils/breadcrumb-buffer.ts +0 -18
  243. package/src/utils/cli-telemetry.test.ts +0 -153
  244. package/src/utils/cli-telemetry.ts +0 -61
  245. package/src/utils/cognito-session.machine.test.ts +0 -109
  246. package/src/utils/cognito-session.refresh.test.ts +0 -84
  247. package/src/utils/cognito-session.test.ts +0 -328
  248. package/src/utils/cognito-session.ts +0 -448
  249. package/src/utils/company-selection-error.test.ts +0 -42
  250. package/src/utils/company-selection-error.ts +0 -45
  251. package/src/utils/contribution-table.ts +0 -83
  252. package/src/utils/environmental-error.test.ts +0 -45
  253. package/src/utils/environmental-error.ts +0 -39
  254. package/src/utils/epipe.test.ts +0 -28
  255. package/src/utils/epipe.ts +0 -29
  256. package/src/utils/expected-cli-error.test.ts +0 -28
  257. package/src/utils/expected-cli-error.ts +0 -39
  258. package/src/utils/feedback-diagnostics.test.ts +0 -183
  259. package/src/utils/feedback-diagnostics.ts +0 -123
  260. package/src/utils/feedback-screenshots.test.ts +0 -134
  261. package/src/utils/feedback-screenshots.ts +0 -124
  262. package/src/utils/feedback-versions.test.ts +0 -98
  263. package/src/utils/feedback-versions.ts +0 -68
  264. package/src/utils/git.ts +0 -74
  265. package/src/utils/integrity.ts +0 -59
  266. package/src/utils/intercepted-process-exit.test.ts +0 -37
  267. package/src/utils/intercepted-process-exit.ts +0 -36
  268. package/src/utils/login-provider.test.ts +0 -42
  269. package/src/utils/login-provider.ts +0 -30
  270. package/src/utils/manifest.test.ts +0 -167
  271. package/src/utils/manifest.ts +0 -131
  272. package/src/utils/pack-contributions.test.ts +0 -603
  273. package/src/utils/pack-contributions.ts +0 -450
  274. package/src/utils/people.ts +0 -215
  275. package/src/utils/registry-client.ts +0 -204
  276. package/src/utils/registry.ts +0 -90
  277. package/src/utils/resolve-vault-credential.test.ts +0 -69
  278. package/src/utils/resolve-vault-credential.ts +0 -60
  279. package/src/utils/sandbox-runner-client.test.ts +0 -279
  280. package/src/utils/sandbox-runner-client.ts +0 -260
  281. package/src/utils/secrets-cache.ts +0 -183
  282. package/src/utils/sentry-identity.ts +0 -45
  283. package/src/utils/settle-with-timeout.test.ts +0 -21
  284. package/src/utils/settle-with-timeout.ts +0 -22
  285. package/src/utils/user-yaml-error.test.ts +0 -24
  286. package/src/utils/user-yaml-error.ts +0 -30
  287. package/src/utils/vault-api.test.ts +0 -536
  288. package/src/utils/vault-api.ts +0 -382
  289. package/src/utils/version-check.test.ts +0 -176
  290. package/src/utils/version-check.ts +0 -155
  291. package/src/utils/version-gate.test.ts +0 -1024
  292. package/src/utils/version-gate.ts +0 -702
  293. package/test/commands/db-tenant-isolation.test.ts +0 -94
  294. package/test/commands/db.test.ts +0 -85
  295. package/test/commands/signals.test.ts +0 -207
  296. package/test/commands/sources.test.ts +0 -231
  297. package/test/e2e/smoke-install-mcp.sh +0 -113
  298. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  299. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  300. package/test/fixtures/signals/action_item/sample.md +0 -16
  301. package/test/fixtures/signals/summary/sample.md +0 -12
  302. package/test/fixtures/sources/meetings/sample.md +0 -25
  303. package/test/helpers/cli-runner.ts +0 -150
  304. package/test/helpers/s3-list-mock.ts +0 -79
  305. package/test/helpers/vault-service-mock.ts +0 -295
  306. package/test/sources-signals/smoke.test.ts +0 -228
  307. package/tsconfig.json +0 -20
  308. package/vitest.config.ts +0 -11
  309. package/vitest.e2e.config.ts +0 -7
@@ -1,1117 +0,0 @@
1
- /**
2
- * Unit tests for `hq cloud provision company <slug>` (cloud-provision.ts).
3
- *
4
- * Coverage targets:
5
- * - validateSlug — pure validation
6
- * - validateManifestAndDir — fs reads against tmp manifest + company dir
7
- * - patchManifest — atomic YAML mutation, preserves siblings, idempotent
8
- * - writeCompanyConfig — atomic JSON write, creates parent .hq/, idempotent
9
- * - createDefaultVaultClient — HTTP surface with mocked global fetch
10
- * - provisionCompany — full orchestrator with all dependencies injected
11
- */
12
-
13
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
- import * as fs from "node:fs";
15
- import * as os from "node:os";
16
- import * as path from "node:path";
17
- import * as yaml from "js-yaml";
18
-
19
- import {
20
- ProvisionError,
21
- type ProvisionResult,
22
- type VaultClient,
23
- type VaultEntity,
24
- companyConfigPath,
25
- companyDirPath,
26
- createDefaultVaultClient,
27
- ensureManifestEntryForProvision,
28
- manifestPath,
29
- patchManifest,
30
- provisionCompany,
31
- validateManifestAndDir,
32
- validateSlug,
33
- writeCompanyConfig,
34
- } from "./cloud-provision.js";
35
-
36
- // ── Test fixtures ────────────────────────────────────────────────────────────
37
-
38
- let tmpRoot: string;
39
-
40
- function seedManifest(
41
- root: string,
42
- companies: Record<string, Record<string, unknown> | null> = {
43
- indigo: { status: "active" },
44
- },
45
- ): void {
46
- const mPath = manifestPath(root);
47
- fs.mkdirSync(path.dirname(mPath), { recursive: true });
48
- fs.writeFileSync(mPath, yaml.dump({ companies }));
49
- }
50
-
51
- function seedCompanyDir(root: string, slug: string): void {
52
- fs.mkdirSync(companyDirPath(root, slug), { recursive: true });
53
- }
54
-
55
- beforeEach(() => {
56
- tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cloud-provision-test-"));
57
- });
58
-
59
- afterEach(() => {
60
- fs.rmSync(tmpRoot, { recursive: true, force: true });
61
- vi.restoreAllMocks();
62
- });
63
-
64
- // ── validateSlug ─────────────────────────────────────────────────────────────
65
-
66
- describe("validateSlug", () => {
67
- it("accepts a valid slug", () => {
68
- expect(() => validateSlug("indigo")).not.toThrow();
69
- expect(() => validateSlug("acme-co")).not.toThrow();
70
- expect(() => validateSlug("acme_co")).not.toThrow();
71
- expect(() => validateSlug("acme.co")).not.toThrow();
72
- expect(() => validateSlug("ACME123")).not.toThrow();
73
- });
74
-
75
- it("rejects an empty slug with code 2", () => {
76
- expect(() => validateSlug("")).toThrowError(ProvisionError);
77
- try {
78
- validateSlug("");
79
- } catch (e) {
80
- expect(e).toBeInstanceOf(ProvisionError);
81
- expect((e as ProvisionError).code).toBe(2);
82
- }
83
- });
84
-
85
- it("rejects whitespace-only slug", () => {
86
- expect(() => validateSlug(" ")).toThrowError(ProvisionError);
87
- });
88
-
89
- it("rejects slugs with invalid characters", () => {
90
- expect(() => validateSlug("acme co")).toThrowError(/Invalid slug/);
91
- expect(() => validateSlug("acme/co")).toThrowError(/Invalid slug/);
92
- expect(() => validateSlug("acme!")).toThrowError(/Invalid slug/);
93
- expect(() => validateSlug("acme$co")).toThrowError(/Invalid slug/);
94
- });
95
-
96
- it('rejects the path-traversal slug "."', () => {
97
- try {
98
- validateSlug(".");
99
- expect.fail("should have thrown");
100
- } catch (e) {
101
- expect(e).toBeInstanceOf(ProvisionError);
102
- expect((e as ProvisionError).code).toBe(2);
103
- expect((e as ProvisionError).message).toMatch(/path-traversal/);
104
- }
105
- });
106
-
107
- it('rejects the path-traversal slug ".."', () => {
108
- try {
109
- validateSlug("..");
110
- expect.fail("should have thrown");
111
- } catch (e) {
112
- expect(e).toBeInstanceOf(ProvisionError);
113
- expect((e as ProvisionError).code).toBe(2);
114
- expect((e as ProvisionError).message).toMatch(/path-traversal/);
115
- }
116
- });
117
-
118
- it('rejects the reserved "personal" slug', () => {
119
- expect(() => validateSlug("personal")).toThrowError(/reserved/);
120
- try {
121
- validateSlug("personal");
122
- } catch (e) {
123
- expect((e as ProvisionError).code).toBe(2);
124
- }
125
- });
126
- });
127
-
128
- // ── validateManifestAndDir ───────────────────────────────────────────────────
129
-
130
- describe("validateManifestAndDir", () => {
131
- it("returns parsed manifest on the happy path", () => {
132
- seedManifest(tmpRoot, { indigo: { status: "active" } });
133
- seedCompanyDir(tmpRoot, "indigo");
134
- const { manifest } = validateManifestAndDir(tmpRoot, "indigo");
135
- expect(manifest.companies?.indigo).toEqual({ status: "active" });
136
- });
137
-
138
- it("throws code 2 if manifest.yaml is missing", () => {
139
- seedCompanyDir(tmpRoot, "indigo");
140
- try {
141
- validateManifestAndDir(tmpRoot, "indigo");
142
- expect.fail("should have thrown");
143
- } catch (e) {
144
- expect(e).toBeInstanceOf(ProvisionError);
145
- expect((e as ProvisionError).code).toBe(2);
146
- expect((e as ProvisionError).message).toMatch(/manifest\.yaml not found/);
147
- }
148
- });
149
-
150
- it("throws code 2 if manifest is malformed (no .companies)", () => {
151
- const mPath = manifestPath(tmpRoot);
152
- fs.mkdirSync(path.dirname(mPath), { recursive: true });
153
- fs.writeFileSync(mPath, "not_companies: 'oops'\n");
154
- seedCompanyDir(tmpRoot, "indigo");
155
- expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
156
- /malformed/,
157
- );
158
- });
159
-
160
- it("throws code 2 if slug is missing from manifest", () => {
161
- seedManifest(tmpRoot, { other: { status: "active" } });
162
- seedCompanyDir(tmpRoot, "indigo");
163
- try {
164
- validateManifestAndDir(tmpRoot, "indigo");
165
- expect.fail("should have thrown");
166
- } catch (e) {
167
- expect((e as ProvisionError).code).toBe(2);
168
- expect((e as ProvisionError).message).toMatch(/not found under \.companies/);
169
- }
170
- });
171
-
172
- it("throws code 2 if company is status: archived", () => {
173
- seedManifest(tmpRoot, { indigo: { status: "archived" } });
174
- seedCompanyDir(tmpRoot, "indigo");
175
- expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
176
- /archived/,
177
- );
178
- });
179
-
180
- it("throws code 2 if company directory is missing", () => {
181
- seedManifest(tmpRoot, { indigo: { status: "active" } });
182
- // Note: NOT calling seedCompanyDir
183
- expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrowError(
184
- /does not exist/,
185
- );
186
- });
187
-
188
- it("accepts a manifest entry that is null (no fields)", () => {
189
- seedManifest(tmpRoot, { indigo: null });
190
- seedCompanyDir(tmpRoot, "indigo");
191
- expect(() => validateManifestAndDir(tmpRoot, "indigo")).not.toThrow();
192
- });
193
- });
194
-
195
- // ── ensureManifestEntryForProvision ──────────────────────────────────────────
196
-
197
- describe("ensureManifestEntryForProvision", () => {
198
- it("inserts an empty entry when slug is missing but company dir exists", () => {
199
- seedManifest(tmpRoot, { other: { status: "active" } });
200
- seedCompanyDir(tmpRoot, "indigo");
201
- ensureManifestEntryForProvision(tmpRoot, "indigo");
202
- const onDisk = yaml.load(
203
- fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
204
- ) as { companies: Record<string, unknown> };
205
- expect(onDisk.companies.indigo).toEqual({});
206
- expect(onDisk.companies.other).toEqual({ status: "active" });
207
- });
208
-
209
- it("validateManifestAndDir then succeeds for the auto-inserted slug", () => {
210
- seedManifest(tmpRoot, { other: { status: "active" } });
211
- seedCompanyDir(tmpRoot, "indigo");
212
- ensureManifestEntryForProvision(tmpRoot, "indigo");
213
- const { manifest } = validateManifestAndDir(tmpRoot, "indigo");
214
- expect(manifest.companies?.indigo).toEqual({});
215
- });
216
-
217
- it("is idempotent — re-run on an already-present entry is a no-op", () => {
218
- seedManifest(tmpRoot, { indigo: { status: "active" } });
219
- seedCompanyDir(tmpRoot, "indigo");
220
- const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
221
- ensureManifestEntryForProvision(tmpRoot, "indigo");
222
- const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
223
- expect(after).toBe(before);
224
- });
225
-
226
- it("does NOT insert when company dir is missing (no typo masking)", () => {
227
- seedManifest(tmpRoot, { other: { status: "active" } });
228
- // No seedCompanyDir — slug looks like a typo.
229
- const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
230
- ensureManifestEntryForProvision(tmpRoot, "indigo");
231
- const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
232
- expect(after).toBe(before);
233
- // Downstream validateManifestAndDir still throws — dir-missing wins over
234
- // slug-missing in the error message because the dir check runs later, but
235
- // either way the user gets exit 2 with no silent mutation.
236
- expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrow();
237
- });
238
-
239
- it("does NOT insert when companies/<slug> exists as a regular file (not a dir)", () => {
240
- seedManifest(tmpRoot, { other: { status: "active" } });
241
- // Create a regular file at companies/indigo (not a directory). Without
242
- // the isDirectory() guard, existsSync would return true and the helper
243
- // would auto-insert — then provisionCompany would create the vault
244
- // entity + patch manifest BEFORE writeCompanyConfig's mkdir failed with
245
- // ENOTDIR. Verify no mutation.
246
- const companiesDir = path.join(tmpRoot, "companies");
247
- fs.mkdirSync(companiesDir, { recursive: true });
248
- fs.writeFileSync(path.join(companiesDir, "indigo"), "not a directory");
249
- const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
250
- ensureManifestEntryForProvision(tmpRoot, "indigo");
251
- const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
252
- expect(after).toBe(before);
253
- });
254
-
255
- it("does NOT insert when manifest file is missing entirely", () => {
256
- seedCompanyDir(tmpRoot, "indigo");
257
- expect(() =>
258
- ensureManifestEntryForProvision(tmpRoot, "indigo"),
259
- ).not.toThrow();
260
- expect(fs.existsSync(manifestPath(tmpRoot))).toBe(false);
261
- });
262
-
263
- it("does NOT insert when manifest is malformed (no .companies map)", () => {
264
- const mPath = manifestPath(tmpRoot);
265
- fs.mkdirSync(path.dirname(mPath), { recursive: true });
266
- const malformed = "not_companies: 'oops'\n";
267
- fs.writeFileSync(mPath, malformed);
268
- seedCompanyDir(tmpRoot, "indigo");
269
- ensureManifestEntryForProvision(tmpRoot, "indigo");
270
- // File on disk unchanged — malformed manifests are left for validation
271
- // to surface, not silently rewritten.
272
- expect(fs.readFileSync(mPath, "utf-8")).toBe(malformed);
273
- });
274
-
275
- it("preserves a null entry (does not promote null → {})", () => {
276
- seedManifest(tmpRoot, { indigo: null });
277
- seedCompanyDir(tmpRoot, "indigo");
278
- ensureManifestEntryForProvision(tmpRoot, "indigo");
279
- const onDisk = yaml.load(
280
- fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
281
- ) as { companies: Record<string, unknown> };
282
- expect(onDisk.companies.indigo).toBeNull();
283
- });
284
-
285
- it('refuses to auto-insert for path-traversal slug "." (defense-in-depth)', () => {
286
- seedManifest(tmpRoot, { other: { status: "active" } });
287
- // `companies/.` resolves to `companies/` itself, which DOES exist on
288
- // disk — without the path-resolve guard, the helper would insert
289
- // `".": {}` into the manifest. Verify it doesn't.
290
- const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
291
- ensureManifestEntryForProvision(tmpRoot, ".");
292
- const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
293
- expect(after).toBe(before);
294
- });
295
-
296
- it('refuses to auto-insert for path-traversal slug ".." (defense-in-depth)', () => {
297
- seedManifest(tmpRoot, { other: { status: "active" } });
298
- // `companies/..` resolves to `tmpRoot` itself, which exists — without
299
- // the path-resolve guard, the helper would insert `"..": {}` into the
300
- // manifest. Verify it doesn't.
301
- const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
302
- ensureManifestEntryForProvision(tmpRoot, "..");
303
- const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
304
- expect(after).toBe(before);
305
- });
306
-
307
- it("refuses to auto-insert when slug contains a path separator", () => {
308
- seedManifest(tmpRoot, { other: { status: "active" } });
309
- // Even if validateSlug were bypassed, a slug like `foo/bar` would land
310
- // in a nested path; the path-resolve guard rejects it.
311
- const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
312
- ensureManifestEntryForProvision(tmpRoot, "foo/bar");
313
- const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
314
- expect(after).toBe(before);
315
- });
316
- });
317
-
318
- // ── patchManifest ────────────────────────────────────────────────────────────
319
-
320
- describe("patchManifest", () => {
321
- beforeEach(() => {
322
- seedManifest(tmpRoot, {
323
- indigo: { status: "active", existing_field: "kept" },
324
- other: { status: "active", cloud_uid: "cmp_other" },
325
- });
326
- });
327
-
328
- it("writes cloud_uid + bucket_name under the target slug", () => {
329
- patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
330
- const after = yaml.load(
331
- fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
332
- ) as { companies: Record<string, Record<string, unknown>> };
333
- expect(after.companies.indigo.cloud_uid).toBe("cmp_01H");
334
- expect(after.companies.indigo.bucket_name).toBe("hq-vault-cmp-01H");
335
- });
336
-
337
- it("preserves sibling fields on the target entry", () => {
338
- patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
339
- const after = yaml.load(
340
- fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
341
- ) as { companies: Record<string, Record<string, unknown>> };
342
- expect(after.companies.indigo.status).toBe("active");
343
- expect(after.companies.indigo.existing_field).toBe("kept");
344
- });
345
-
346
- it("preserves other companies untouched", () => {
347
- patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
348
- const after = yaml.load(
349
- fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
350
- ) as { companies: Record<string, Record<string, unknown>> };
351
- expect(after.companies.other).toEqual({
352
- status: "active",
353
- cloud_uid: "cmp_other",
354
- });
355
- });
356
-
357
- it("is idempotent — same inputs produce identical bytes", () => {
358
- patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
359
- const first = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
360
- patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
361
- const second = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
362
- expect(second).toBe(first);
363
- });
364
-
365
- it("does not leave a .tmp file behind on success", () => {
366
- patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
367
- const dir = fs.readdirSync(path.dirname(manifestPath(tmpRoot)));
368
- expect(dir.filter((f) => f.includes(".tmp."))).toEqual([]);
369
- });
370
-
371
- it("creates the .companies entry if the slug had a null value", () => {
372
- seedManifest(tmpRoot, { newco: null });
373
- patchManifest(tmpRoot, "newco", "cmp_NEW", "hq-vault-cmp-NEW");
374
- const after = yaml.load(
375
- fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
376
- ) as { companies: Record<string, Record<string, unknown>> };
377
- expect(after.companies.newco).toEqual({
378
- cloud_uid: "cmp_NEW",
379
- bucket_name: "hq-vault-cmp-NEW",
380
- });
381
- });
382
-
383
- it("returns true when it actually changes a value", () => {
384
- expect(patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H")).toBe(
385
- true,
386
- );
387
- });
388
-
389
- // ── Conflict-loop regression ───────────────────────────────────────────────
390
- // Repro for the recurring HQ Sync conflict loop on companies/manifest.yaml:
391
- // a re-provision / menubar Connect on an already-provisioned company used to
392
- // unconditionally reserialize the manifest via yaml.dump — stripping comments
393
- // and reflowing — even though nothing changed. The initial-sync step then
394
- // pushed that comment-stripped form, so it perpetually diverged from any
395
- // peer/cloud copy still holding the commented form, re-firing a conflict on
396
- // every sync. The fix: skip the write entirely when values already match.
397
- describe("conflict-loop regression — no-op re-provision must not churn the file", () => {
398
- // A manifest in the on-disk shape /newcompany writes: header comment, blank
399
- // lines, per-entry comment — none of which yaml.dump round-trips.
400
- const COMMENTED_MANIFEST = `# HQ companies manifest — source of truth for routing.
401
- # Synced across machines via the personal vault; edit with care.
402
-
403
- companies:
404
- indigo:
405
- name: Indigo
406
- status: active
407
- cloud_uid: cmp_01H
408
- bucket_name: hq-vault-cmp-01H
409
- acme:
410
- name: Acme Corp
411
- status: active
412
- `;
413
-
414
- beforeEach(() => {
415
- const mPath = manifestPath(tmpRoot);
416
- fs.mkdirSync(path.dirname(mPath), { recursive: true });
417
- fs.writeFileSync(mPath, COMMENTED_MANIFEST);
418
- });
419
-
420
- it("returns false and leaves the file byte-for-byte intact (comments preserved) when values already match", () => {
421
- const changed = patchManifest(
422
- tmpRoot,
423
- "indigo",
424
- "cmp_01H",
425
- "hq-vault-cmp-01H",
426
- );
427
- expect(changed).toBe(false);
428
- // The whole point: no rewrite at all — comments + layout survive, so the
429
- // synced bytes never diverge and no conflict is seeded.
430
- expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(
431
- COMMENTED_MANIFEST,
432
- );
433
- });
434
-
435
- it("re-running many times never churns the on-disk bytes (loop cannot start)", () => {
436
- for (let i = 0; i < 5; i++) {
437
- patchManifest(tmpRoot, "indigo", "cmp_01H", "hq-vault-cmp-01H");
438
- }
439
- expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(
440
- COMMENTED_MANIFEST,
441
- );
442
- });
443
-
444
- it("a genuine change writes once, then converges — subsequent re-runs are no-ops", () => {
445
- // First real change (acme had no cloud_uid) writes once and returns true.
446
- expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
447
- true,
448
- );
449
- const afterFirst = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
450
- // Every subsequent identical provision is a no-op — stable bytes, false.
451
- expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
452
- false,
453
- );
454
- expect(patchManifest(tmpRoot, "acme", "cmp_AC", "hq-vault-cmp-AC")).toBe(
455
- false,
456
- );
457
- expect(fs.readFileSync(manifestPath(tmpRoot), "utf-8")).toBe(afterFirst);
458
- });
459
- });
460
- });
461
-
462
- // ── writeCompanyConfig ───────────────────────────────────────────────────────
463
-
464
- describe("writeCompanyConfig", () => {
465
- beforeEach(() => {
466
- seedCompanyDir(tmpRoot, "indigo");
467
- });
468
-
469
- it("writes valid JSON with all four fields", () => {
470
- writeCompanyConfig(tmpRoot, "indigo", {
471
- companyUid: "cmp_01H",
472
- companySlug: "indigo",
473
- bucketName: "hq-vault-cmp-01H",
474
- vaultApiUrl: "https://vault.example.com",
475
- });
476
- const cPath = companyConfigPath(tmpRoot, "indigo");
477
- const parsed = JSON.parse(fs.readFileSync(cPath, "utf-8"));
478
- expect(parsed).toEqual({
479
- companyUid: "cmp_01H",
480
- companySlug: "indigo",
481
- bucketName: "hq-vault-cmp-01H",
482
- vaultApiUrl: "https://vault.example.com",
483
- });
484
- });
485
-
486
- it("creates the parent .hq/ directory if missing", () => {
487
- const hqDir = path.join(companyDirPath(tmpRoot, "indigo"), ".hq");
488
- expect(fs.existsSync(hqDir)).toBe(false);
489
- writeCompanyConfig(tmpRoot, "indigo", {
490
- companyUid: "cmp_01H",
491
- companySlug: "indigo",
492
- bucketName: "b",
493
- vaultApiUrl: "u",
494
- });
495
- expect(fs.existsSync(hqDir)).toBe(true);
496
- });
497
-
498
- it("is idempotent — same inputs produce identical bytes", () => {
499
- const config = {
500
- companyUid: "cmp_01H",
501
- companySlug: "indigo",
502
- bucketName: "b",
503
- vaultApiUrl: "u",
504
- };
505
- writeCompanyConfig(tmpRoot, "indigo", config);
506
- const first = fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8");
507
- writeCompanyConfig(tmpRoot, "indigo", config);
508
- const second = fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8");
509
- expect(second).toBe(first);
510
- });
511
-
512
- it("does not leave a .tmp file behind on success", () => {
513
- writeCompanyConfig(tmpRoot, "indigo", {
514
- companyUid: "cmp_01H",
515
- companySlug: "indigo",
516
- bucketName: "b",
517
- vaultApiUrl: "u",
518
- });
519
- const hqDir = path.join(companyDirPath(tmpRoot, "indigo"), ".hq");
520
- const entries = fs.readdirSync(hqDir);
521
- expect(entries.filter((f) => f.includes(".tmp."))).toEqual([]);
522
- });
523
- });
524
-
525
- // ── createDefaultVaultClient ─────────────────────────────────────────────────
526
-
527
- describe("createDefaultVaultClient", () => {
528
- const apiUrl = "https://vault.example.com";
529
- const token = "test-token";
530
-
531
- it("findCompanyBySlug returns the entity on 200", async () => {
532
- const entity: VaultEntity = {
533
- uid: "cmp_01H",
534
- type: "company",
535
- slug: "indigo",
536
- name: "Indigo",
537
- bucketName: "hq-vault-cmp-01H",
538
- };
539
- vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
540
- new Response(JSON.stringify({ entity }), { status: 200 }),
541
- );
542
- const client = createDefaultVaultClient(apiUrl, token);
543
- const out = await client.findCompanyBySlug("indigo");
544
- expect(out).toEqual(entity);
545
- });
546
-
547
- it("findCompanyBySlug returns null on 404", async () => {
548
- vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
549
- new Response("not found", { status: 404 }),
550
- );
551
- const client = createDefaultVaultClient(apiUrl, token);
552
- expect(await client.findCompanyBySlug("missing")).toBeNull();
553
- });
554
-
555
- it("findCompanyBySlug throws ProvisionError code 1 on 500", async () => {
556
- vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
557
- new Response("kaboom", { status: 500, statusText: "Server Error" }),
558
- );
559
- const client = createDefaultVaultClient(apiUrl, token);
560
- try {
561
- await client.findCompanyBySlug("indigo");
562
- expect.fail("should have thrown");
563
- } catch (e) {
564
- expect(e).toBeInstanceOf(ProvisionError);
565
- expect((e as ProvisionError).code).toBe(1);
566
- expect((e as ProvisionError).message).toMatch(/500/);
567
- }
568
- });
569
-
570
- it("findCompanyBySlug throws code 1 if 200 has no entity body", async () => {
571
- vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
572
- new Response(JSON.stringify({}), { status: 200 }),
573
- );
574
- const client = createDefaultVaultClient(apiUrl, token);
575
- await expect(client.findCompanyBySlug("indigo")).rejects.toThrowError(
576
- /no entity body/,
577
- );
578
- });
579
-
580
- it("createCompanyEntity returns the entity on 201", async () => {
581
- const entity: VaultEntity = {
582
- uid: "cmp_01H",
583
- type: "company",
584
- slug: "indigo",
585
- name: "Indigo",
586
- bucketName: "hq-vault-cmp-01H",
587
- };
588
- const fetchSpy = vi
589
- .spyOn(globalThis, "fetch")
590
- .mockResolvedValueOnce(
591
- new Response(JSON.stringify({ entity }), { status: 201 }),
592
- );
593
- const client = createDefaultVaultClient(apiUrl, token);
594
- const out = await client.createCompanyEntity({
595
- slug: "indigo",
596
- name: "Indigo",
597
- });
598
- expect(out).toEqual(entity);
599
- // Verify request shape
600
- const call = fetchSpy.mock.calls[0];
601
- expect(call[0]).toBe(`${apiUrl}/entity`);
602
- expect((call[1] as RequestInit)?.method).toBe("POST");
603
- const body = JSON.parse(((call[1] as RequestInit)?.body as string) ?? "{}");
604
- expect(body).toEqual({ type: "company", slug: "indigo", name: "Indigo" });
605
- });
606
-
607
- it("createCompanyEntity throws code 1 on 409 conflict", async () => {
608
- vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
609
- new Response("conflict", { status: 409, statusText: "Conflict" }),
610
- );
611
- const client = createDefaultVaultClient(apiUrl, token);
612
- try {
613
- await client.createCompanyEntity({ slug: "indigo", name: "Indigo" });
614
- expect.fail("should have thrown");
615
- } catch (e) {
616
- expect((e as ProvisionError).code).toBe(1);
617
- expect((e as ProvisionError).message).toMatch(/409/);
618
- }
619
- });
620
-
621
- it("createCompanyEntity sends ownerUid when provided", async () => {
622
- const entity: VaultEntity = {
623
- uid: "cmp_01H",
624
- type: "company",
625
- slug: "indigo",
626
- name: "Indigo",
627
- bucketName: "b",
628
- };
629
- const fetchSpy = vi
630
- .spyOn(globalThis, "fetch")
631
- .mockResolvedValueOnce(
632
- new Response(JSON.stringify({ entity }), { status: 201 }),
633
- );
634
- const client = createDefaultVaultClient(apiUrl, token);
635
- await client.createCompanyEntity({
636
- slug: "indigo",
637
- name: "Indigo",
638
- ownerUid: "person_01H",
639
- });
640
- const body = JSON.parse(
641
- ((fetchSpy.mock.calls[0]?.[1] as RequestInit)?.body as string) ?? "{}",
642
- );
643
- expect(body.ownerUid).toBe("person_01H");
644
- });
645
- });
646
-
647
- // ── provisionCompany (orchestrator) ──────────────────────────────────────────
648
-
649
- describe("provisionCompany", () => {
650
- const vaultApiUrl = "https://vault.example.com";
651
- const accessToken = "test-token";
652
-
653
- function setupValid(): void {
654
- seedManifest(tmpRoot, { indigo: { status: "active" } });
655
- seedCompanyDir(tmpRoot, "indigo");
656
- }
657
-
658
- function makeVaultClient(overrides: Partial<VaultClient> = {}): VaultClient {
659
- return {
660
- listMyPersonEntities: vi.fn().mockResolvedValue([
661
- { uid: "prs_01H", type: "person", slug: "test-user", name: "Test User" },
662
- ]),
663
- // Default to "slug available in caller's namespace" so happy-path
664
- // tests fall through to createCompanyEntity. Reuse-path tests
665
- // override with {available: false, conflictingCompanyUid: ...}
666
- // and supply a getCompanyByUid that returns the existing entity.
667
- checkSlugInMyNamespace: vi
668
- .fn()
669
- .mockResolvedValue({ available: true }),
670
- getCompanyByUid: vi
671
- .fn()
672
- .mockRejectedValue(
673
- new Error(
674
- "getCompanyByUid called without an explicit per-test mock — happy path should never hit it",
675
- ),
676
- ),
677
- findCompanyBySlug: vi.fn().mockResolvedValue(null),
678
- createCompanyEntity: vi.fn(),
679
- ...overrides,
680
- };
681
- }
682
-
683
- it("happy path — entity not found → POST → manifest + config + sync", async () => {
684
- setupValid();
685
- const entity: VaultEntity = {
686
- uid: "cmp_01H",
687
- type: "company",
688
- slug: "indigo",
689
- name: "Indigo",
690
- bucketName: "hq-vault-cmp-01H",
691
- kmsKeyId: "key-123",
692
- };
693
- const vaultClient = makeVaultClient({
694
- findCompanyBySlug: vi.fn().mockResolvedValue(null),
695
- createCompanyEntity: vi.fn().mockResolvedValue(entity),
696
- });
697
- const runInitialSync = vi
698
- .fn()
699
- .mockResolvedValue({ filesUploaded: 7, bytesUploaded: 1024 });
700
-
701
- const result = await provisionCompany({
702
- slug: "indigo",
703
- name: "Indigo",
704
- hqRoot: tmpRoot,
705
- vaultApiUrl,
706
- vaultClient,
707
- resolveAccessToken: async () => accessToken,
708
- runInitialSync,
709
- log: () => {},
710
- });
711
-
712
- expect(result).toEqual<ProvisionResult>({
713
- ok: true,
714
- company_slug: "indigo",
715
- cloud_uid: "cmp_01H",
716
- bucket_name: "hq-vault-cmp-01H",
717
- vault_api_url: vaultApiUrl,
718
- kms_key_id: "key-123",
719
- created_entity: true,
720
- manifest_patched: true,
721
- config_written: true,
722
- initial_sync: { ok: true, files_uploaded: 7, bytes_uploaded: 1024 },
723
- });
724
- // Manifest was actually patched on disk
725
- const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
726
- companies: Record<string, Record<string, unknown>>;
727
- };
728
- expect(m.companies.indigo.cloud_uid).toBe("cmp_01H");
729
- expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-01H");
730
- // Config was written
731
- const c = JSON.parse(
732
- fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8"),
733
- );
734
- expect(c.companyUid).toBe("cmp_01H");
735
- // POST happened
736
- expect(vaultClient.createCompanyEntity).toHaveBeenCalledOnce();
737
- });
738
-
739
- it("folder-only path — slug missing from manifest but dir exists → auto-insert → succeeds", async () => {
740
- // The hq-sync Connect dead-end this PR fixes: companies/indigo/ exists on
741
- // disk, the user clicks Connect, but indigo isn't under .companies yet.
742
- seedManifest(tmpRoot, { other: { status: "active" } });
743
- seedCompanyDir(tmpRoot, "indigo");
744
- const entity: VaultEntity = {
745
- uid: "cmp_02H",
746
- type: "company",
747
- slug: "indigo",
748
- name: "Indigo",
749
- bucketName: "hq-vault-cmp-02H",
750
- kmsKeyId: null,
751
- };
752
- const vaultClient = makeVaultClient({
753
- findCompanyBySlug: vi.fn().mockResolvedValue(null),
754
- createCompanyEntity: vi.fn().mockResolvedValue(entity),
755
- });
756
- const result = await provisionCompany({
757
- slug: "indigo",
758
- name: "Indigo",
759
- hqRoot: tmpRoot,
760
- vaultApiUrl,
761
- vaultClient,
762
- resolveAccessToken: async () => accessToken,
763
- runInitialSync: vi
764
- .fn()
765
- .mockResolvedValue({ filesUploaded: 0, bytesUploaded: 0 }),
766
- log: () => {},
767
- });
768
- expect(result.ok).toBe(true);
769
- expect(result.created_entity).toBe(true);
770
- expect(result.manifest_patched).toBe(true);
771
- const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
772
- companies: Record<string, Record<string, unknown>>;
773
- };
774
- expect(m.companies.indigo.cloud_uid).toBe("cmp_02H");
775
- expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-02H");
776
- // Sibling untouched.
777
- expect(m.companies.other).toEqual({ status: "active" });
778
- });
779
-
780
- it("typo guard — slug missing AND dir missing still throws code 2 (no auto-heal)", async () => {
781
- seedManifest(tmpRoot, { other: { status: "active" } });
782
- // NOT calling seedCompanyDir — a slug with no folder is a typo.
783
- await expect(
784
- provisionCompany({
785
- slug: "tyypo",
786
- hqRoot: tmpRoot,
787
- vaultApiUrl,
788
- vaultClient: makeVaultClient(),
789
- resolveAccessToken: async () => accessToken,
790
- log: () => {},
791
- }),
792
- ).rejects.toMatchObject({ code: 2 });
793
- // Manifest unchanged — typo did not silently insert an entry.
794
- const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
795
- companies: Record<string, unknown>;
796
- };
797
- expect(m.companies.tyypo).toBeUndefined();
798
- });
799
-
800
- it("idempotent path — entity found → no POST → still patches + syncs → created_entity=false", async () => {
801
- setupValid();
802
- const entity: VaultEntity = {
803
- uid: "cmp_01H",
804
- type: "company",
805
- slug: "indigo",
806
- name: "Indigo",
807
- bucketName: "hq-vault-cmp-01H",
808
- kmsKeyId: null,
809
- };
810
- const vaultClient = makeVaultClient({
811
- // Reuse path under the per-user-namespace model: checkSlugInMyNamespace
812
- // reports `available: false` with the existing entity's uid, and
813
- // getCompanyByUid materializes the full entity for downstream use.
814
- checkSlugInMyNamespace: vi
815
- .fn()
816
- .mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
817
- getCompanyByUid: vi.fn().mockResolvedValue(entity),
818
- createCompanyEntity: vi.fn(),
819
- });
820
- const result = await provisionCompany({
821
- slug: "indigo",
822
- hqRoot: tmpRoot,
823
- vaultApiUrl,
824
- vaultClient,
825
- resolveAccessToken: async () => accessToken,
826
- runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
827
- log: () => {},
828
- });
829
- expect(result.created_entity).toBe(false);
830
- expect(result.kms_key_id).toBeNull();
831
- expect(vaultClient.createCompanyEntity).not.toHaveBeenCalled();
832
- expect(vaultClient.getCompanyByUid).toHaveBeenCalledWith(entity.uid);
833
- });
834
-
835
- it("throws code 1 when entity has no bucketName (incomplete provisioning)", async () => {
836
- setupValid();
837
- const entity: VaultEntity = {
838
- uid: "cmp_01H",
839
- type: "company",
840
- slug: "indigo",
841
- name: "Indigo",
842
- // bucketName intentionally absent
843
- };
844
- const vaultClient = makeVaultClient({
845
- // Same reuse-path mock shape as the idempotent-path test above.
846
- checkSlugInMyNamespace: vi
847
- .fn()
848
- .mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
849
- getCompanyByUid: vi.fn().mockResolvedValue(entity),
850
- });
851
- try {
852
- await provisionCompany({
853
- slug: "indigo",
854
- hqRoot: tmpRoot,
855
- vaultApiUrl,
856
- vaultClient,
857
- resolveAccessToken: async () => accessToken,
858
- runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
859
- log: () => {},
860
- });
861
- expect.fail("should have thrown");
862
- } catch (e) {
863
- expect(e).toBeInstanceOf(ProvisionError);
864
- expect((e as ProvisionError).code).toBe(1);
865
- expect((e as ProvisionError).partial?.cloud_uid).toBe("cmp_01H");
866
- expect((e as ProvisionError).partial?.manifest_patched).toBe(false);
867
- }
868
- });
869
-
870
- it("throws code 3 when initial sync fails — manifest + config STILL written", async () => {
871
- setupValid();
872
- const entity: VaultEntity = {
873
- uid: "cmp_01H",
874
- type: "company",
875
- slug: "indigo",
876
- name: "Indigo",
877
- bucketName: "hq-vault-cmp-01H",
878
- };
879
- const vaultClient = makeVaultClient({
880
- findCompanyBySlug: vi.fn().mockResolvedValue(null),
881
- createCompanyEntity: vi.fn().mockResolvedValue(entity),
882
- });
883
- const runInitialSync = vi
884
- .fn()
885
- .mockRejectedValue(new Error("S3 timeout"));
886
-
887
- try {
888
- await provisionCompany({
889
- slug: "indigo",
890
- hqRoot: tmpRoot,
891
- vaultApiUrl,
892
- vaultClient,
893
- resolveAccessToken: async () => accessToken,
894
- runInitialSync,
895
- log: () => {},
896
- });
897
- expect.fail("should have thrown");
898
- } catch (e) {
899
- expect(e).toBeInstanceOf(ProvisionError);
900
- expect((e as ProvisionError).code).toBe(3);
901
- expect((e as ProvisionError).partial?.manifest_patched).toBe(true);
902
- expect((e as ProvisionError).partial?.config_written).toBe(true);
903
- expect((e as ProvisionError).partial?.initial_sync?.ok).toBe(false);
904
- expect((e as ProvisionError).partial?.initial_sync?.error).toMatch(
905
- /S3 timeout/,
906
- );
907
- }
908
- // Manifest WAS written despite the sync failure (matches partial=true)
909
- const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
910
- companies: Record<string, Record<string, unknown>>;
911
- };
912
- expect(m.companies.indigo.cloud_uid).toBe("cmp_01H");
913
- });
914
-
915
- it("throws code 2 on invalid slug before any vault call", async () => {
916
- seedCompanyDir(tmpRoot, "indigo"); // dir exists but slug is invalid
917
- const vaultClient = makeVaultClient();
918
- try {
919
- await provisionCompany({
920
- slug: "personal",
921
- hqRoot: tmpRoot,
922
- vaultApiUrl,
923
- vaultClient,
924
- resolveAccessToken: async () => accessToken,
925
- runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
926
- log: () => {},
927
- });
928
- expect.fail("should have thrown");
929
- } catch (e) {
930
- expect((e as ProvisionError).code).toBe(2);
931
- expect(vaultClient.findCompanyBySlug).not.toHaveBeenCalled();
932
- }
933
- });
934
-
935
- it("uses slug as the entity name when --name is omitted", async () => {
936
- setupValid();
937
- const entity: VaultEntity = {
938
- uid: "cmp_01H",
939
- type: "company",
940
- slug: "indigo",
941
- name: "indigo",
942
- bucketName: "b",
943
- };
944
- const createSpy = vi.fn().mockResolvedValue(entity);
945
- const vaultClient = makeVaultClient({
946
- findCompanyBySlug: vi.fn().mockResolvedValue(null),
947
- createCompanyEntity: createSpy,
948
- });
949
- await provisionCompany({
950
- slug: "indigo",
951
- // name omitted on purpose
952
- hqRoot: tmpRoot,
953
- vaultApiUrl,
954
- vaultClient,
955
- resolveAccessToken: async () => accessToken,
956
- runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
957
- log: () => {},
958
- });
959
- expect(createSpy).toHaveBeenCalledWith({
960
- slug: "indigo",
961
- name: "indigo",
962
- ownerUid: undefined,
963
- });
964
- });
965
-
966
- it("skipInitialSync=true — runner is NOT called; result has initial_sync.skipped", async () => {
967
- setupValid();
968
- const entity: VaultEntity = {
969
- uid: "cmp_01H",
970
- type: "company",
971
- slug: "indigo",
972
- name: "Indigo",
973
- bucketName: "hq-vault-cmp-01H",
974
- };
975
- const vaultClient = makeVaultClient({
976
- findCompanyBySlug: vi.fn().mockResolvedValue(null),
977
- createCompanyEntity: vi.fn().mockResolvedValue(entity),
978
- });
979
- const runInitialSync = vi.fn();
980
-
981
- const result = await provisionCompany({
982
- slug: "indigo",
983
- hqRoot: tmpRoot,
984
- vaultApiUrl,
985
- vaultClient,
986
- resolveAccessToken: async () => accessToken,
987
- runInitialSync,
988
- skipInitialSync: true,
989
- log: () => {},
990
- });
991
-
992
- expect(runInitialSync).not.toHaveBeenCalled();
993
- expect(result.initial_sync).toEqual({ skipped: true });
994
- expect(result.ok).toBe(true);
995
- expect(result.cloud_uid).toBe("cmp_01H");
996
- expect(result.manifest_patched).toBe(true);
997
- expect(result.config_written).toBe(true);
998
- });
999
-
1000
- it("skipInitialSync=true — manifest + .hq/config.json are STILL written", async () => {
1001
- setupValid();
1002
- const entity: VaultEntity = {
1003
- uid: "cmp_SKIP",
1004
- type: "company",
1005
- slug: "indigo",
1006
- name: "Indigo",
1007
- bucketName: "hq-vault-cmp-SKIP",
1008
- };
1009
- const vaultClient = makeVaultClient({
1010
- findCompanyBySlug: vi.fn().mockResolvedValue(null),
1011
- createCompanyEntity: vi.fn().mockResolvedValue(entity),
1012
- });
1013
- await provisionCompany({
1014
- slug: "indigo",
1015
- hqRoot: tmpRoot,
1016
- vaultApiUrl,
1017
- vaultClient,
1018
- resolveAccessToken: async () => accessToken,
1019
- runInitialSync: vi.fn(),
1020
- skipInitialSync: true,
1021
- log: () => {},
1022
- });
1023
- // Manifest patched on disk
1024
- const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
1025
- companies: Record<string, Record<string, unknown>>;
1026
- };
1027
- expect(m.companies.indigo.cloud_uid).toBe("cmp_SKIP");
1028
- expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-SKIP");
1029
- // .hq/config.json written
1030
- const c = JSON.parse(
1031
- fs.readFileSync(companyConfigPath(tmpRoot, "indigo"), "utf-8"),
1032
- );
1033
- expect(c.companyUid).toBe("cmp_SKIP");
1034
- expect(c.bucketName).toBe("hq-vault-cmp-SKIP");
1035
- });
1036
-
1037
- // ── Pre-flight: caller MUST have a person entity ─────────────────────────
1038
- //
1039
- // Without one, the initial-sync step at the end of provision returns 403
1040
- // "no person entity" from /sts/vend and leaves the operator with a half-
1041
- // built cloud company. The pre-flight catches this BEFORE any cloud-side
1042
- // resource creation. Documented in the 2026-05-14 setup-session deep dive.
1043
-
1044
- it("pre-flight fails fast with code 2 when caller has no person entity — no resources touched", async () => {
1045
- setupValid();
1046
- const createCompanyEntity = vi.fn();
1047
- const runInitialSync = vi.fn();
1048
- const vaultClient = makeVaultClient({
1049
- listMyPersonEntities: vi.fn().mockResolvedValue([]),
1050
- findCompanyBySlug: vi.fn(),
1051
- createCompanyEntity,
1052
- });
1053
-
1054
- let caught: unknown;
1055
- try {
1056
- await provisionCompany({
1057
- slug: "indigo",
1058
- hqRoot: tmpRoot,
1059
- vaultApiUrl,
1060
- vaultClient,
1061
- resolveAccessToken: async () => accessToken,
1062
- runInitialSync,
1063
- log: () => {},
1064
- });
1065
- } catch (e) {
1066
- caught = e;
1067
- }
1068
-
1069
- expect(caught).toBeInstanceOf(ProvisionError);
1070
- expect((caught as ProvisionError).code).toBe(2);
1071
- expect((caught as ProvisionError).message).toMatch(/No person entity/);
1072
- expect((caught as ProvisionError).message).toMatch(/hq onboard/);
1073
-
1074
- // No cloud-side mutations: no entity lookup, no entity creation, no sync.
1075
- expect(vaultClient.findCompanyBySlug).not.toHaveBeenCalled();
1076
- expect(createCompanyEntity).not.toHaveBeenCalled();
1077
- expect(runInitialSync).not.toHaveBeenCalled();
1078
-
1079
- // No disk-side mutations: manifest still untouched.
1080
- const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
1081
- companies: Record<string, Record<string, unknown>>;
1082
- };
1083
- expect(m.companies.indigo.cloud_uid).toBeUndefined();
1084
- expect(m.companies.indigo.bucket_name).toBeUndefined();
1085
- expect(fs.existsSync(companyConfigPath(tmpRoot, "indigo"))).toBe(false);
1086
- });
1087
-
1088
- it("pre-flight passes when caller has a person entity — flow continues to provisioning", async () => {
1089
- setupValid();
1090
- const entity: VaultEntity = {
1091
- uid: "cmp_OK", type: "company", slug: "indigo", name: "Indigo",
1092
- bucketName: "hq-vault-cmp-OK", kmsKeyId: "key-OK",
1093
- };
1094
- const listSpy = vi.fn().mockResolvedValue([
1095
- { uid: "prs_known", type: "person", slug: "joey-muller", name: "Joey Muller" },
1096
- ]);
1097
- const vaultClient = makeVaultClient({
1098
- listMyPersonEntities: listSpy,
1099
- findCompanyBySlug: vi.fn().mockResolvedValue(null),
1100
- createCompanyEntity: vi.fn().mockResolvedValue(entity),
1101
- });
1102
-
1103
- const result = await provisionCompany({
1104
- slug: "indigo",
1105
- hqRoot: tmpRoot,
1106
- vaultApiUrl,
1107
- vaultClient,
1108
- resolveAccessToken: async () => accessToken,
1109
- runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
1110
- log: () => {},
1111
- });
1112
-
1113
- expect(listSpy).toHaveBeenCalledOnce();
1114
- expect(result.ok).toBe(true);
1115
- expect(result.cloud_uid).toBe("cmp_OK");
1116
- });
1117
- });