@indigoai-us/hq-cli 5.77.14 → 5.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/CHANGELOG.md +23 -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/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +42 -2
  33. package/dist/commands/pkg-install.js +39 -2
  34. package/dist/commands/run.js +6 -2
  35. package/dist/commands/secrets.js +120 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2256
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs-update-api-key.test.ts +0 -105
  146. package/src/commands/packs.test.ts +0 -88
  147. package/src/commands/packs.ts +0 -622
  148. package/src/commands/people.test.ts +0 -807
  149. package/src/commands/people.ts +0 -422
  150. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  151. package/src/commands/pkg-install.ts +0 -221
  152. package/src/commands/pkg-list.test.ts +0 -79
  153. package/src/commands/pkg-list.ts +0 -13
  154. package/src/commands/pkg-remove.ts +0 -71
  155. package/src/commands/pkg-update.ts +0 -189
  156. package/src/commands/publish.test.ts +0 -650
  157. package/src/commands/publish.ts +0 -580
  158. package/src/commands/reindex.test.ts +0 -259
  159. package/src/commands/reindex.ts +0 -274
  160. package/src/commands/rescue.test.ts +0 -39
  161. package/src/commands/rescue.ts +0 -210
  162. package/src/commands/run.env-local.test.ts +0 -88
  163. package/src/commands/run.test.ts +0 -45
  164. package/src/commands/run.ts +0 -190
  165. package/src/commands/safe-extract.test.ts +0 -459
  166. package/src/commands/safe-extract.ts +0 -444
  167. package/src/commands/secrets-scope.test.ts +0 -56
  168. package/src/commands/secrets-scope.ts +0 -32
  169. package/src/commands/secrets.parse-destination.test.ts +0 -38
  170. package/src/commands/secrets.test.ts +0 -2050
  171. package/src/commands/secrets.ts +0 -2249
  172. package/src/commands/signals.ts +0 -351
  173. package/src/commands/skill.test.ts +0 -770
  174. package/src/commands/skill.ts +0 -796
  175. package/src/commands/sources.ts +0 -362
  176. package/src/commands/sync-mode.test.ts +0 -366
  177. package/src/commands/sync-mode.ts +0 -387
  178. package/src/commands/sync-narrow.test.ts +0 -633
  179. package/src/commands/sync-narrow.ts +0 -555
  180. package/src/commands/sync.ts +0 -165
  181. package/src/commands/team-sync.ts +0 -628
  182. package/src/commands/update.ts +0 -71
  183. package/src/commands/whoami.ts +0 -83
  184. package/src/commands/workers.test.ts +0 -158
  185. package/src/commands/workers.ts +0 -301
  186. package/src/index.test.ts +0 -32
  187. package/src/index.ts +0 -20
  188. package/src/lib/db/control-plane.test.ts +0 -59
  189. package/src/lib/db/control-plane.ts +0 -113
  190. package/src/lib/db/local.test.ts +0 -81
  191. package/src/lib/db/local.ts +0 -148
  192. package/src/lib/db/migrate.test.ts +0 -133
  193. package/src/lib/db/migrate.ts +0 -137
  194. package/src/lib/db/paths.test.ts +0 -112
  195. package/src/lib/db/paths.ts +0 -128
  196. package/src/lib/db/remote-engine.test.ts +0 -44
  197. package/src/lib/db/remote-engine.ts +0 -148
  198. package/src/lib/db/remote-sql.test.ts +0 -32
  199. package/src/lib/db/remote-sql.ts +0 -62
  200. package/src/lib/db/sql.test.ts +0 -106
  201. package/src/lib/db/sql.ts +0 -192
  202. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  203. package/src/lib/local-tree-diff.test.ts +0 -301
  204. package/src/lib/local-tree-diff.ts +0 -362
  205. package/src/lib/narrow-hint-banner.test.ts +0 -235
  206. package/src/lib/narrow-hint-banner.ts +0 -212
  207. package/src/lib/plan-limit-nag.test.ts +0 -317
  208. package/src/lib/plan-limit-nag.ts +0 -264
  209. package/src/main.ts +0 -371
  210. package/src/node-preflight.test.ts +0 -60
  211. package/src/node-preflight.ts +0 -67
  212. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  213. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  214. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  215. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  216. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  217. package/src/outpost/session-heartbeat.test.ts +0 -459
  218. package/src/outpost/session-heartbeat.ts +0 -877
  219. package/src/packaging.test.ts +0 -109
  220. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  221. package/src/run/discover-schemas.test.ts +0 -153
  222. package/src/run/discover-schemas.ts +0 -79
  223. package/src/run/hq-plugin.test.ts +0 -371
  224. package/src/run/hq-plugin.ts +0 -277
  225. package/src/run/varlock-shape.test.ts +0 -57
  226. package/src/schemas/hq-package.schema.json +0 -155
  227. package/src/sentry-before-send.test.ts +0 -359
  228. package/src/sentry-before-send.ts +0 -221
  229. package/src/sentry-epipe.test.ts +0 -37
  230. package/src/sentry-release.test.ts +0 -54
  231. package/src/sentry.test.ts +0 -24
  232. package/src/sentry.ts +0 -54
  233. package/src/strategies/link.ts +0 -62
  234. package/src/strategies/merge.ts +0 -142
  235. package/src/types.ts +0 -146
  236. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  237. package/src/utils/auth-error.test.ts +0 -40
  238. package/src/utils/auth-error.ts +0 -42
  239. package/src/utils/billing-gate.test.ts +0 -95
  240. package/src/utils/billing-gate.ts +0 -228
  241. package/src/utils/breadcrumb-buffer.ts +0 -18
  242. package/src/utils/cli-telemetry.test.ts +0 -153
  243. package/src/utils/cli-telemetry.ts +0 -61
  244. package/src/utils/cognito-session.machine.test.ts +0 -109
  245. package/src/utils/cognito-session.refresh.test.ts +0 -84
  246. package/src/utils/cognito-session.test.ts +0 -328
  247. package/src/utils/cognito-session.ts +0 -448
  248. package/src/utils/company-selection-error.test.ts +0 -42
  249. package/src/utils/company-selection-error.ts +0 -45
  250. package/src/utils/contribution-table.ts +0 -83
  251. package/src/utils/environmental-error.test.ts +0 -45
  252. package/src/utils/environmental-error.ts +0 -39
  253. package/src/utils/epipe.test.ts +0 -28
  254. package/src/utils/epipe.ts +0 -29
  255. package/src/utils/expected-cli-error.test.ts +0 -28
  256. package/src/utils/expected-cli-error.ts +0 -39
  257. package/src/utils/feedback-diagnostics.test.ts +0 -183
  258. package/src/utils/feedback-diagnostics.ts +0 -123
  259. package/src/utils/feedback-screenshots.test.ts +0 -134
  260. package/src/utils/feedback-screenshots.ts +0 -124
  261. package/src/utils/feedback-versions.test.ts +0 -98
  262. package/src/utils/feedback-versions.ts +0 -68
  263. package/src/utils/git.ts +0 -74
  264. package/src/utils/integrity.ts +0 -59
  265. package/src/utils/intercepted-process-exit.test.ts +0 -37
  266. package/src/utils/intercepted-process-exit.ts +0 -36
  267. package/src/utils/login-provider.test.ts +0 -42
  268. package/src/utils/login-provider.ts +0 -30
  269. package/src/utils/manifest.test.ts +0 -167
  270. package/src/utils/manifest.ts +0 -131
  271. package/src/utils/pack-contributions.test.ts +0 -603
  272. package/src/utils/pack-contributions.ts +0 -450
  273. package/src/utils/people.ts +0 -215
  274. package/src/utils/registry-client.ts +0 -204
  275. package/src/utils/registry.ts +0 -90
  276. package/src/utils/resolve-vault-credential.test.ts +0 -69
  277. package/src/utils/resolve-vault-credential.ts +0 -60
  278. package/src/utils/sandbox-runner-client.test.ts +0 -279
  279. package/src/utils/sandbox-runner-client.ts +0 -260
  280. package/src/utils/secrets-cache.ts +0 -183
  281. package/src/utils/sentry-identity.ts +0 -45
  282. package/src/utils/settle-with-timeout.test.ts +0 -21
  283. package/src/utils/settle-with-timeout.ts +0 -22
  284. package/src/utils/user-yaml-error.test.ts +0 -24
  285. package/src/utils/user-yaml-error.ts +0 -30
  286. package/src/utils/vault-api.test.ts +0 -536
  287. package/src/utils/vault-api.ts +0 -382
  288. package/src/utils/version-check.test.ts +0 -176
  289. package/src/utils/version-check.ts +0 -155
  290. package/src/utils/version-gate.test.ts +0 -1024
  291. package/src/utils/version-gate.ts +0 -702
  292. package/test/commands/db-tenant-isolation.test.ts +0 -94
  293. package/test/commands/db.test.ts +0 -85
  294. package/test/commands/signals.test.ts +0 -207
  295. package/test/commands/sources.test.ts +0 -231
  296. package/test/e2e/smoke-install-mcp.sh +0 -113
  297. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  298. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  299. package/test/fixtures/signals/action_item/sample.md +0 -16
  300. package/test/fixtures/signals/summary/sample.md +0 -12
  301. package/test/fixtures/sources/meetings/sample.md +0 -25
  302. package/test/helpers/cli-runner.ts +0 -150
  303. package/test/helpers/s3-list-mock.ts +0 -79
  304. package/test/helpers/vault-service-mock.ts +0 -295
  305. package/test/sources-signals/smoke.test.ts +0 -228
  306. package/tsconfig.json +0 -20
  307. package/vitest.config.ts +0 -11
  308. 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
- });