@indigoai-us/hq-cli 5.13.1 → 5.14.1

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.
@@ -19,7 +19,8 @@
19
19
  * HQ_VAULT_API_URL — vault-service API Gateway URL
20
20
  */
21
21
 
22
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="71d8f748-f249-50df-aa1f-eae381ea608d")}catch(e){}}();
22
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="6a8ac157-0903-513d-b4c5-45fb532b8026")}catch(e){}}();
23
+ import * as fs from "fs";
23
24
  import * as os from "os";
24
25
  import * as path from "path";
25
26
  import chalk from "chalk";
@@ -41,7 +42,53 @@ export const DEFAULT_COGNITO = {
41
42
  : "Google",
42
43
  };
43
44
  export const DEFAULT_VAULT_API_URL = process.env.HQ_VAULT_API_URL ?? "https://hqapi.getindigo.ai";
44
- export const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
45
+ /**
46
+ * Resolve the default HQ tree root for cloud-aware subcommands.
47
+ *
48
+ * Priority order:
49
+ * 1. `$HQ_ROOT` env var (explicit user override)
50
+ * 2. Walk up from `process.cwd()` to the nearest dir containing BOTH a
51
+ * `core.yaml` file AND a `companies/` directory (root-unique marker
52
+ * pair — see note below).
53
+ * 3. Fall back to `~/hq` (the historical default)
54
+ *
55
+ * Why both markers?
56
+ * The HQ root has `core.yaml` AND a sibling `companies/` directory. The
57
+ * synced `core/` subtree (which is itself part of the root's personal-vault
58
+ * scope) ALSO contains a `core.yaml` (the template's version-source-of-
59
+ * truth), but does NOT contain `companies/`. Single-marker `core.yaml`
60
+ * detection would stop at `<hqRoot>/core/` when the CLI is launched from
61
+ * somewhere inside that subtree, and downstream `companies/` lookups would
62
+ * silently miss the real content. Requiring `companies/` as well guarantees
63
+ * we resolve to the actual HQ root (Codex P2 on hq#146).
64
+ *
65
+ * Evaluated once at module load — commander.js `.option()` callers pin the
66
+ * value at registration time, which matches the user's actual cwd at process
67
+ * start. Re-importable as a function for tests and command-time resolution.
68
+ */
69
+ export function resolveDefaultHqRoot() {
70
+ if (process.env.HQ_ROOT)
71
+ return path.resolve(process.env.HQ_ROOT);
72
+ let cur = path.resolve(process.cwd());
73
+ while (cur !== path.dirname(cur)) {
74
+ if (isHqRoot(cur))
75
+ return cur;
76
+ cur = path.dirname(cur);
77
+ }
78
+ return path.join(os.homedir(), "hq");
79
+ }
80
+ /** True iff `dir` looks like an HQ root (has core.yaml + companies/ dir). */
81
+ function isHqRoot(dir) {
82
+ if (!fs.existsSync(path.join(dir, "core.yaml")))
83
+ return false;
84
+ try {
85
+ return fs.statSync(path.join(dir, "companies")).isDirectory();
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export const DEFAULT_HQ_ROOT = resolveDefaultHqRoot();
45
92
  /**
46
93
  * Return a non-expired Cognito access token, refreshing or browser-logging-in
47
94
  * as needed. Cache lives at ~/.hq/cognito-tokens.json.
@@ -108,4 +155,4 @@ export async function refreshCachedSession() {
108
155
  }
109
156
  }
110
157
  //# sourceMappingURL=cognito-session.js.map
111
- //# debugId=71d8f748-f249-50df-aa1f-eae381ea608d
158
+ //# debugId=6a8ac157-0903-513d-b4c5-45fb532b8026
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.13.1",
3
+ "version": "5.14.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1 +1,9 @@
1
- export const CLI_VERSION = "5.12.1";
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ const here = path.dirname(fileURLToPath(import.meta.url));
6
+ const pkgPath = path.resolve(here, "..", "package.json");
7
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
8
+
9
+ export const CLI_VERSION: string = pkg.version;
@@ -24,6 +24,7 @@ import {
24
24
  companyConfigPath,
25
25
  companyDirPath,
26
26
  createDefaultVaultClient,
27
+ ensureManifestEntryForProvision,
27
28
  manifestPath,
28
29
  patchManifest,
29
30
  provisionCompany,
@@ -92,6 +93,28 @@ describe("validateSlug", () => {
92
93
  expect(() => validateSlug("acme$co")).toThrowError(/Invalid slug/);
93
94
  });
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
+
95
118
  it('rejects the reserved "personal" slug', () => {
96
119
  expect(() => validateSlug("personal")).toThrowError(/reserved/);
97
120
  try {
@@ -169,6 +192,113 @@ describe("validateManifestAndDir", () => {
169
192
  });
170
193
  });
171
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 manifest file is missing entirely", () => {
240
+ seedCompanyDir(tmpRoot, "indigo");
241
+ expect(() =>
242
+ ensureManifestEntryForProvision(tmpRoot, "indigo"),
243
+ ).not.toThrow();
244
+ expect(fs.existsSync(manifestPath(tmpRoot))).toBe(false);
245
+ });
246
+
247
+ it("does NOT insert when manifest is malformed (no .companies map)", () => {
248
+ const mPath = manifestPath(tmpRoot);
249
+ fs.mkdirSync(path.dirname(mPath), { recursive: true });
250
+ const malformed = "not_companies: 'oops'\n";
251
+ fs.writeFileSync(mPath, malformed);
252
+ seedCompanyDir(tmpRoot, "indigo");
253
+ ensureManifestEntryForProvision(tmpRoot, "indigo");
254
+ // File on disk unchanged — malformed manifests are left for validation
255
+ // to surface, not silently rewritten.
256
+ expect(fs.readFileSync(mPath, "utf-8")).toBe(malformed);
257
+ });
258
+
259
+ it("preserves a null entry (does not promote null → {})", () => {
260
+ seedManifest(tmpRoot, { indigo: null });
261
+ seedCompanyDir(tmpRoot, "indigo");
262
+ ensureManifestEntryForProvision(tmpRoot, "indigo");
263
+ const onDisk = yaml.load(
264
+ fs.readFileSync(manifestPath(tmpRoot), "utf-8"),
265
+ ) as { companies: Record<string, unknown> };
266
+ expect(onDisk.companies.indigo).toBeNull();
267
+ });
268
+
269
+ it('refuses to auto-insert for path-traversal slug "." (defense-in-depth)', () => {
270
+ seedManifest(tmpRoot, { other: { status: "active" } });
271
+ // `companies/.` resolves to `companies/` itself, which DOES exist on
272
+ // disk — without the path-resolve guard, the helper would insert
273
+ // `".": {}` into the manifest. Verify it doesn't.
274
+ const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
275
+ ensureManifestEntryForProvision(tmpRoot, ".");
276
+ const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
277
+ expect(after).toBe(before);
278
+ });
279
+
280
+ it('refuses to auto-insert for path-traversal slug ".." (defense-in-depth)', () => {
281
+ seedManifest(tmpRoot, { other: { status: "active" } });
282
+ // `companies/..` resolves to `tmpRoot` itself, which exists — without
283
+ // the path-resolve guard, the helper would insert `"..": {}` into the
284
+ // manifest. Verify it doesn't.
285
+ const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
286
+ ensureManifestEntryForProvision(tmpRoot, "..");
287
+ const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
288
+ expect(after).toBe(before);
289
+ });
290
+
291
+ it("refuses to auto-insert when slug contains a path separator", () => {
292
+ seedManifest(tmpRoot, { other: { status: "active" } });
293
+ // Even if validateSlug were bypassed, a slug like `foo/bar` would land
294
+ // in a nested path; the path-resolve guard rejects it.
295
+ const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
296
+ ensureManifestEntryForProvision(tmpRoot, "foo/bar");
297
+ const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
298
+ expect(after).toBe(before);
299
+ });
300
+ });
301
+
172
302
  // ── patchManifest ────────────────────────────────────────────────────────────
173
303
 
174
304
  describe("patchManifest", () => {
@@ -433,6 +563,9 @@ describe("provisionCompany", () => {
433
563
 
434
564
  function makeVaultClient(overrides: Partial<VaultClient> = {}): VaultClient {
435
565
  return {
566
+ listMyPersonEntities: vi.fn().mockResolvedValue([
567
+ { uid: "prs_01H", type: "person", slug: "test-user", name: "Test User" },
568
+ ]),
436
569
  findCompanyBySlug: vi.fn().mockResolvedValue(null),
437
570
  createCompanyEntity: vi.fn(),
438
571
  ...overrides,
@@ -495,6 +628,67 @@ describe("provisionCompany", () => {
495
628
  expect(vaultClient.createCompanyEntity).toHaveBeenCalledOnce();
496
629
  });
497
630
 
631
+ it("folder-only path — slug missing from manifest but dir exists → auto-insert → succeeds", async () => {
632
+ // The hq-sync Connect dead-end this PR fixes: companies/indigo/ exists on
633
+ // disk, the user clicks Connect, but indigo isn't under .companies yet.
634
+ seedManifest(tmpRoot, { other: { status: "active" } });
635
+ seedCompanyDir(tmpRoot, "indigo");
636
+ const entity: VaultEntity = {
637
+ uid: "cmp_02H",
638
+ type: "company",
639
+ slug: "indigo",
640
+ name: "Indigo",
641
+ bucketName: "hq-vault-cmp-02H",
642
+ kmsKeyId: null,
643
+ };
644
+ const vaultClient = makeVaultClient({
645
+ findCompanyBySlug: vi.fn().mockResolvedValue(null),
646
+ createCompanyEntity: vi.fn().mockResolvedValue(entity),
647
+ });
648
+ const result = await provisionCompany({
649
+ slug: "indigo",
650
+ name: "Indigo",
651
+ hqRoot: tmpRoot,
652
+ vaultApiUrl,
653
+ vaultClient,
654
+ resolveAccessToken: async () => accessToken,
655
+ runInitialSync: vi
656
+ .fn()
657
+ .mockResolvedValue({ filesUploaded: 0, bytesUploaded: 0 }),
658
+ log: () => {},
659
+ });
660
+ expect(result.ok).toBe(true);
661
+ expect(result.created_entity).toBe(true);
662
+ expect(result.manifest_patched).toBe(true);
663
+ const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
664
+ companies: Record<string, Record<string, unknown>>;
665
+ };
666
+ expect(m.companies.indigo.cloud_uid).toBe("cmp_02H");
667
+ expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-02H");
668
+ // Sibling untouched.
669
+ expect(m.companies.other).toEqual({ status: "active" });
670
+ });
671
+
672
+ it("typo guard — slug missing AND dir missing still throws code 2 (no auto-heal)", async () => {
673
+ seedManifest(tmpRoot, { other: { status: "active" } });
674
+ // NOT calling seedCompanyDir — a slug with no folder is a typo.
675
+ await expect(
676
+ provisionCompany({
677
+ slug: "tyypo",
678
+ hqRoot: tmpRoot,
679
+ vaultApiUrl,
680
+ vaultClient: makeVaultClient(),
681
+ resolveAccessToken: async () => accessToken,
682
+ log: () => {},
683
+ }),
684
+ ).rejects.toMatchObject({ code: 2 });
685
+ // Manifest unchanged — typo did not silently insert an entry.
686
+ const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
687
+ companies: Record<string, unknown>;
688
+ };
689
+ expect(m.companies.tyypo).toBeUndefined();
690
+ });
691
+
498
692
  it("idempotent path — entity found → no POST → still patches + syncs → created_entity=false", async () => {
499
693
  setupValid();
500
694
  const entity: VaultEntity = {
@@ -720,4 +914,85 @@ describe("provisionCompany", () => {
720
914
  expect(c.companyUid).toBe("cmp_SKIP");
721
915
  expect(c.bucketName).toBe("hq-vault-cmp-SKIP");
722
916
  });
917
+
918
+ // ── Pre-flight: caller MUST have a person entity ─────────────────────────
919
+ //
920
+ // Without one, the initial-sync step at the end of provision returns 403
921
+ // "no person entity" from /sts/vend and leaves the operator with a half-
922
+ // built cloud company. The pre-flight catches this BEFORE any cloud-side
923
+ // resource creation. Documented in the 2026-05-14 setup-session deep dive.
924
+
925
+ it("pre-flight fails fast with code 2 when caller has no person entity — no resources touched", async () => {
926
+ setupValid();
927
+ const createCompanyEntity = vi.fn();
928
+ const runInitialSync = vi.fn();
929
+ const vaultClient = makeVaultClient({
930
+ listMyPersonEntities: vi.fn().mockResolvedValue([]),
931
+ findCompanyBySlug: vi.fn(),
932
+ createCompanyEntity,
933
+ });
934
+
935
+ let caught: unknown;
936
+ try {
937
+ await provisionCompany({
938
+ slug: "indigo",
939
+ hqRoot: tmpRoot,
940
+ vaultApiUrl,
941
+ vaultClient,
942
+ resolveAccessToken: async () => accessToken,
943
+ runInitialSync,
944
+ log: () => {},
945
+ });
946
+ } catch (e) {
947
+ caught = e;
948
+ }
949
+
950
+ expect(caught).toBeInstanceOf(ProvisionError);
951
+ expect((caught as ProvisionError).code).toBe(2);
952
+ expect((caught as ProvisionError).message).toMatch(/No person entity/);
953
+ expect((caught as ProvisionError).message).toMatch(/hq onboard/);
954
+
955
+ // No cloud-side mutations: no entity lookup, no entity creation, no sync.
956
+ expect(vaultClient.findCompanyBySlug).not.toHaveBeenCalled();
957
+ expect(createCompanyEntity).not.toHaveBeenCalled();
958
+ expect(runInitialSync).not.toHaveBeenCalled();
959
+
960
+ // No disk-side mutations: manifest still untouched.
961
+ const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
962
+ companies: Record<string, Record<string, unknown>>;
963
+ };
964
+ expect(m.companies.indigo.cloud_uid).toBeUndefined();
965
+ expect(m.companies.indigo.bucket_name).toBeUndefined();
966
+ expect(fs.existsSync(companyConfigPath(tmpRoot, "indigo"))).toBe(false);
967
+ });
968
+
969
+ it("pre-flight passes when caller has a person entity — flow continues to provisioning", async () => {
970
+ setupValid();
971
+ const entity: VaultEntity = {
972
+ uid: "cmp_OK", type: "company", slug: "indigo", name: "Indigo",
973
+ bucketName: "hq-vault-cmp-OK", kmsKeyId: "key-OK",
974
+ };
975
+ const listSpy = vi.fn().mockResolvedValue([
976
+ { uid: "prs_known", type: "person", slug: "joey-muller", name: "Joey Muller" },
977
+ ]);
978
+ const vaultClient = makeVaultClient({
979
+ listMyPersonEntities: listSpy,
980
+ findCompanyBySlug: vi.fn().mockResolvedValue(null),
981
+ createCompanyEntity: vi.fn().mockResolvedValue(entity),
982
+ });
983
+
984
+ const result = await provisionCompany({
985
+ slug: "indigo",
986
+ hqRoot: tmpRoot,
987
+ vaultApiUrl,
988
+ vaultClient,
989
+ resolveAccessToken: async () => accessToken,
990
+ runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
991
+ log: () => {},
992
+ });
993
+
994
+ expect(listSpy).toHaveBeenCalledOnce();
995
+ expect(result.ok).toBe(true);
996
+ expect(result.cloud_uid).toBe("cmp_OK");
997
+ });
723
998
  });
@@ -20,7 +20,10 @@
20
20
  * Exit codes:
21
21
  * 0 — success (and `initial_sync.ok=true`)
22
22
  * 1 — vault auth/network/API error (no entity provisioned)
23
- * 2 — invalid slug, company missing from manifest, or company dir missing
23
+ * 2 — invalid slug, company dir missing, or company is status=archived.
24
+ * Folder-only states (companies/<slug>/ exists on disk but no manifest
25
+ * entry) self-heal via ensureManifestEntryForProvision before
26
+ * validation runs, so a missing manifest entry alone is NOT an exit-2.
24
27
  * 3 — sync failure after entity provisioned (cloud_uid in JSON;
25
28
  * `initial_sync.ok=false`). Manifest + config may have been written.
26
29
  */
@@ -121,6 +124,15 @@ interface InitialSyncArgs {
121
124
 
122
125
  /** Vault HTTP client interface — minimal surface for entity ops. */
123
126
  export interface VaultClient {
127
+ /**
128
+ * List every person entity visible to the caller (scoped server-side by the
129
+ * caller's Cognito identity). Used by `provisionCompany` as a pre-flight
130
+ * to verify the caller has been onboarded before mutating any cloud-side
131
+ * resources — the initial-sync step at the end of provision needs to vend
132
+ * STS credentials, which the server only does for callers with a person
133
+ * entity.
134
+ */
135
+ listMyPersonEntities(): Promise<VaultEntity[]>;
124
136
  findCompanyBySlug(slug: string): Promise<VaultEntity | null>;
125
137
  createCompanyEntity(input: {
126
138
  slug: string;
@@ -144,12 +156,14 @@ export class ProvisionError extends Error {
144
156
  // ── Validation ───────────────────────────────────────────────────────────────
145
157
 
146
158
  const SLUG_REGEX = /^[A-Za-z0-9._-]+$/;
147
- const FORBIDDEN_SLUGS = new Set(["personal"]);
159
+ const FORBIDDEN_SLUGS = new Set(["personal", ".", ".."]);
148
160
 
149
161
  /**
150
162
  * Validate a company slug per the contract: alphanumeric / dot / dash / underscore,
151
- * non-empty, and never `"personal"` (which is auto-provisioned per-user, not
152
- * promoted via this subcommand).
163
+ * non-empty, never `"."` or `".."` (path-traversal would resolve to
164
+ * `companies/` itself or `<hqRoot>` and write outside the intended company
165
+ * directory), and never `"personal"` (auto-provisioned per-user, not promoted
166
+ * via this subcommand).
153
167
  *
154
168
  * Throws ProvisionError with code=2 on failure.
155
169
  */
@@ -164,10 +178,11 @@ export function validateSlug(slug: string): void {
164
178
  );
165
179
  }
166
180
  if (FORBIDDEN_SLUGS.has(slug)) {
167
- throw new ProvisionError(
168
- 2,
169
- `Slug "${slug}" is reserved (auto-provisioned per-user, not eligible for cloud promotion)`,
170
- );
181
+ const reason =
182
+ slug === "personal"
183
+ ? "auto-provisioned per-user, not eligible for cloud promotion"
184
+ : "path-traversal slug (would resolve to a directory outside the intended company folder)";
185
+ throw new ProvisionError(2, `Slug "${slug}" is reserved (${reason})`);
171
186
  }
172
187
  }
173
188
 
@@ -196,6 +211,11 @@ export function companyConfigPath(hqRoot: string, slug: string): string {
196
211
  * - company is `status: archived`
197
212
  * - `companies/<slug>/` does not exist
198
213
  *
214
+ * Strict on purpose — callers that want the "auto-insert a missing entry when
215
+ * the local folder exists" affordance (cloud-provision's fault-tolerant flow)
216
+ * MUST call `ensureManifestEntryForProvision` first. cloud-demote and other
217
+ * sensitive callers depend on this strict failure for typo-catching.
218
+ *
199
219
  * Returns the parsed manifest (so the caller can re-use it for the patch step).
200
220
  */
201
221
  export function validateManifestAndDir(
@@ -246,6 +266,68 @@ export function validateManifestAndDir(
246
266
  return { manifest };
247
267
  }
248
268
 
269
+ /**
270
+ * Provision-only pre-step. If `companies/<slug>/` exists on disk but the slug
271
+ * is absent from `manifest.yaml`, atomically insert an empty entry
272
+ * (`<slug>: {}`) so the downstream `validateManifestAndDir` + `patchManifest`
273
+ * flow has something to operate on. Heals the common folder-only state
274
+ * (manual mkdir, or older tools that didn't patch manifest.yaml) that hq-sync's
275
+ * Connect button used to dead-end on with exit 2.
276
+ *
277
+ * No-op in every other case:
278
+ * - manifest file missing / malformed (validation will surface)
279
+ * - slug already present (any value, including null — preserved as-is)
280
+ * - `companies/<slug>/` does not exist (validation will surface as a typo)
281
+ *
282
+ * This affordance is provision-specific. `cloud-demote` and other strict
283
+ * callers MUST NOT call this — their typo-catching contract depends on
284
+ * `validateManifestAndDir` throwing on missing-slug.
285
+ */
286
+ export function ensureManifestEntryForProvision(
287
+ hqRoot: string,
288
+ slug: string,
289
+ ): void {
290
+ const mPath = manifestPath(hqRoot);
291
+ if (!fs.existsSync(mPath)) return;
292
+ // Defense-in-depth against path-traversal slugs (`.`, `..`, anything that
293
+ // resolves outside companies/). validateSlug rejects these upstream, but
294
+ // this helper is exported and might be called directly by future code —
295
+ // never auto-insert based on a path that isn't a literal direct child of
296
+ // `companies/`. fs.realpathSync would also catch symlink-escapes, but the
297
+ // directory may legitimately not exist yet on disk; path.resolve gives us
298
+ // the canonical lexical form without a stat call.
299
+ const companiesDir = path.resolve(hqRoot, "companies");
300
+ const expected = path.resolve(companiesDir, slug);
301
+ const expectedParent = path.dirname(expected);
302
+ if (expectedParent !== companiesDir) return;
303
+ if (path.basename(expected) !== slug) return;
304
+ const dir = companyDirPath(hqRoot, slug);
305
+ if (!fs.existsSync(dir)) return;
306
+ const raw = fs.readFileSync(mPath, "utf-8");
307
+ let parsed: unknown;
308
+ try {
309
+ parsed = yaml.load(raw);
310
+ } catch {
311
+ return;
312
+ }
313
+ if (
314
+ !parsed ||
315
+ typeof parsed !== "object" ||
316
+ !("companies" in parsed) ||
317
+ typeof (parsed as ManifestDoc).companies !== "object"
318
+ ) {
319
+ return;
320
+ }
321
+ const doc = parsed as ManifestDoc;
322
+ if (!doc.companies) doc.companies = {};
323
+ if (doc.companies[slug] !== undefined) return;
324
+ doc.companies[slug] = {};
325
+ const dump = yaml.dump(doc, { lineWidth: -1, noRefs: true });
326
+ const tmp = `${mPath}.tmp.${process.pid}`;
327
+ fs.writeFileSync(tmp, dump);
328
+ fs.renameSync(tmp, mPath);
329
+ }
330
+
249
331
  // ── Manifest patching (atomic) ───────────────────────────────────────────────
250
332
 
251
333
  /**
@@ -345,6 +427,20 @@ export function createDefaultVaultClient(
345
427
  Authorization: `Bearer ${accessToken}`,
346
428
  };
347
429
  return {
430
+ async listMyPersonEntities(): Promise<VaultEntity[]> {
431
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/by-type/person`;
432
+ const res = await fetch(url, { method: "GET", headers });
433
+ if (res.status === 404) return [];
434
+ if (!res.ok) {
435
+ const body = await safeBody(res);
436
+ throw new ProvisionError(
437
+ 1,
438
+ `Vault GET /entity/by-type/person failed: ${res.status} ${res.statusText} — ${body}`,
439
+ );
440
+ }
441
+ const data = (await res.json()) as { entities?: VaultEntity[] };
442
+ return data.entities ?? [];
443
+ },
348
444
  async findCompanyBySlug(slug: string): Promise<VaultEntity | null> {
349
445
  const url = `${apiUrl.replace(/\/$/, "")}/entity/by-slug/company/${encodeURIComponent(
350
446
  slug,
@@ -450,8 +546,13 @@ export async function provisionCompany(
450
546
  ): Promise<ProvisionResult> {
451
547
  const log = options.log ?? ((msg: string) => process.stderr.write(`[hq cloud provision] ${msg}\n`));
452
548
 
453
- // Step 1+2+3: validate slug, manifest, dir
549
+ // Step 1+2+3: validate slug, auto-heal a folder-only manifest, then validate
550
+ // the resulting manifest + dir. The auto-heal is provision-specific: it
551
+ // inserts an empty entry when companies/<slug>/ exists on disk but the slug
552
+ // is missing from manifest.yaml. validateManifestAndDir stays strict so
553
+ // other callers (cloud-demote) keep their typo-catching guard.
454
554
  validateSlug(options.slug);
555
+ ensureManifestEntryForProvision(options.hqRoot, options.slug);
455
556
  validateManifestAndDir(options.hqRoot, options.slug);
456
557
  log(`validated slug=${options.slug}`);
457
558
 
@@ -466,6 +567,27 @@ export async function provisionCompany(
466
567
  options.vaultClient ??
467
568
  createDefaultVaultClient(options.vaultApiUrl, accessToken);
468
569
 
570
+ // Pre-flight: caller MUST have a registered person entity. Without one,
571
+ // the initial-sync step at the end of this flow (STS /sts/vend) returns
572
+ // 403 "no person entity" and leaves the operator with a half-built cloud
573
+ // company — vault entity created, S3 bucket provisioned, manifest patched,
574
+ // .hq/config.json written, but nothing actually syncing. This was the
575
+ // primary failure mode in the 2026-05-14 setup-session deep dive
576
+ // (Joey Muller / sum-digital).
577
+ //
578
+ // Failing here, BEFORE any cloud-side resource creation, leaves the operator
579
+ // with no cleanup work — they fix the underlying onboarding gap (run
580
+ // `hq onboard` / sign in with the correct federated identity) and re-run
581
+ // `hq cloud provision company <slug>` cleanly.
582
+ const persons = await vaultClient.listMyPersonEntities();
583
+ if (persons.length === 0) {
584
+ throw new ProvisionError(
585
+ 2,
586
+ 'No person entity found for this Cognito identity. Run `hq onboard` first to create your HQ identity, then re-run `hq cloud provision company`. (Provision was halted before any cloud-side resources were created.)',
587
+ );
588
+ }
589
+ log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
590
+
469
591
  let entity = await vaultClient.findCompanyBySlug(options.slug);
470
592
  let createdEntity = false;
471
593
  if (entity) {