@indigoai-us/hq-cli 5.14.0 → 5.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.14.0",
3
+ "version": "5.15.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -29,8 +29,9 @@
29
29
  "devDependencies": {
30
30
  "@types/js-yaml": "^4.0.9",
31
31
  "@types/node": "^22.0.0",
32
+ "@types/semver": "^7.5.8",
32
33
  "typescript": "^5.7.0",
33
- "@types/semver": "^7.5.8"
34
+ "vitest": "^4.1.2"
34
35
  },
35
36
  "repository": {
36
37
  "type": "git",
@@ -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,129 @@ 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 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
+
172
318
  // ── patchManifest ────────────────────────────────────────────────────────────
173
319
 
174
320
  describe("patchManifest", () => {
@@ -433,6 +579,9 @@ describe("provisionCompany", () => {
433
579
 
434
580
  function makeVaultClient(overrides: Partial<VaultClient> = {}): VaultClient {
435
581
  return {
582
+ listMyPersonEntities: vi.fn().mockResolvedValue([
583
+ { uid: "prs_01H", type: "person", slug: "test-user", name: "Test User" },
584
+ ]),
436
585
  findCompanyBySlug: vi.fn().mockResolvedValue(null),
437
586
  createCompanyEntity: vi.fn(),
438
587
  ...overrides,
@@ -495,6 +644,67 @@ describe("provisionCompany", () => {
495
644
  expect(vaultClient.createCompanyEntity).toHaveBeenCalledOnce();
496
645
  });
497
646
 
647
+ it("folder-only path — slug missing from manifest but dir exists → auto-insert → succeeds", async () => {
648
+ // The hq-sync Connect dead-end this PR fixes: companies/indigo/ exists on
649
+ // disk, the user clicks Connect, but indigo isn't under .companies yet.
650
+ seedManifest(tmpRoot, { other: { status: "active" } });
651
+ seedCompanyDir(tmpRoot, "indigo");
652
+ const entity: VaultEntity = {
653
+ uid: "cmp_02H",
654
+ type: "company",
655
+ slug: "indigo",
656
+ name: "Indigo",
657
+ bucketName: "hq-vault-cmp-02H",
658
+ kmsKeyId: null,
659
+ };
660
+ const vaultClient = makeVaultClient({
661
+ findCompanyBySlug: vi.fn().mockResolvedValue(null),
662
+ createCompanyEntity: vi.fn().mockResolvedValue(entity),
663
+ });
664
+ const result = await provisionCompany({
665
+ slug: "indigo",
666
+ name: "Indigo",
667
+ hqRoot: tmpRoot,
668
+ vaultApiUrl,
669
+ vaultClient,
670
+ resolveAccessToken: async () => accessToken,
671
+ runInitialSync: vi
672
+ .fn()
673
+ .mockResolvedValue({ filesUploaded: 0, bytesUploaded: 0 }),
674
+ log: () => {},
675
+ });
676
+ expect(result.ok).toBe(true);
677
+ expect(result.created_entity).toBe(true);
678
+ expect(result.manifest_patched).toBe(true);
679
+ const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
680
+ companies: Record<string, Record<string, unknown>>;
681
+ };
682
+ expect(m.companies.indigo.cloud_uid).toBe("cmp_02H");
683
+ expect(m.companies.indigo.bucket_name).toBe("hq-vault-cmp-02H");
684
+ // Sibling untouched.
685
+ expect(m.companies.other).toEqual({ status: "active" });
686
+ });
687
+
688
+ it("typo guard — slug missing AND dir missing still throws code 2 (no auto-heal)", async () => {
689
+ seedManifest(tmpRoot, { other: { status: "active" } });
690
+ // NOT calling seedCompanyDir — a slug with no folder is a typo.
691
+ await expect(
692
+ provisionCompany({
693
+ slug: "tyypo",
694
+ hqRoot: tmpRoot,
695
+ vaultApiUrl,
696
+ vaultClient: makeVaultClient(),
697
+ resolveAccessToken: async () => accessToken,
698
+ log: () => {},
699
+ }),
700
+ ).rejects.toMatchObject({ code: 2 });
701
+ // Manifest unchanged — typo did not silently insert an entry.
702
+ const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
703
+ companies: Record<string, unknown>;
704
+ };
705
+ expect(m.companies.tyypo).toBeUndefined();
706
+ });
707
+
498
708
  it("idempotent path — entity found → no POST → still patches + syncs → created_entity=false", async () => {
499
709
  setupValid();
500
710
  const entity: VaultEntity = {
@@ -720,4 +930,85 @@ describe("provisionCompany", () => {
720
930
  expect(c.companyUid).toBe("cmp_SKIP");
721
931
  expect(c.bucketName).toBe("hq-vault-cmp-SKIP");
722
932
  });
933
+
934
+ // ── Pre-flight: caller MUST have a person entity ─────────────────────────
935
+ //
936
+ // Without one, the initial-sync step at the end of provision returns 403
937
+ // "no person entity" from /sts/vend and leaves the operator with a half-
938
+ // built cloud company. The pre-flight catches this BEFORE any cloud-side
939
+ // resource creation. Documented in the 2026-05-14 setup-session deep dive.
940
+
941
+ it("pre-flight fails fast with code 2 when caller has no person entity — no resources touched", async () => {
942
+ setupValid();
943
+ const createCompanyEntity = vi.fn();
944
+ const runInitialSync = vi.fn();
945
+ const vaultClient = makeVaultClient({
946
+ listMyPersonEntities: vi.fn().mockResolvedValue([]),
947
+ findCompanyBySlug: vi.fn(),
948
+ createCompanyEntity,
949
+ });
950
+
951
+ let caught: unknown;
952
+ try {
953
+ await provisionCompany({
954
+ slug: "indigo",
955
+ hqRoot: tmpRoot,
956
+ vaultApiUrl,
957
+ vaultClient,
958
+ resolveAccessToken: async () => accessToken,
959
+ runInitialSync,
960
+ log: () => {},
961
+ });
962
+ } catch (e) {
963
+ caught = e;
964
+ }
965
+
966
+ expect(caught).toBeInstanceOf(ProvisionError);
967
+ expect((caught as ProvisionError).code).toBe(2);
968
+ expect((caught as ProvisionError).message).toMatch(/No person entity/);
969
+ expect((caught as ProvisionError).message).toMatch(/hq onboard/);
970
+
971
+ // No cloud-side mutations: no entity lookup, no entity creation, no sync.
972
+ expect(vaultClient.findCompanyBySlug).not.toHaveBeenCalled();
973
+ expect(createCompanyEntity).not.toHaveBeenCalled();
974
+ expect(runInitialSync).not.toHaveBeenCalled();
975
+
976
+ // No disk-side mutations: manifest still untouched.
977
+ const m = yaml.load(fs.readFileSync(manifestPath(tmpRoot), "utf-8")) as {
978
+ companies: Record<string, Record<string, unknown>>;
979
+ };
980
+ expect(m.companies.indigo.cloud_uid).toBeUndefined();
981
+ expect(m.companies.indigo.bucket_name).toBeUndefined();
982
+ expect(fs.existsSync(companyConfigPath(tmpRoot, "indigo"))).toBe(false);
983
+ });
984
+
985
+ it("pre-flight passes when caller has a person entity — flow continues to provisioning", async () => {
986
+ setupValid();
987
+ const entity: VaultEntity = {
988
+ uid: "cmp_OK", type: "company", slug: "indigo", name: "Indigo",
989
+ bucketName: "hq-vault-cmp-OK", kmsKeyId: "key-OK",
990
+ };
991
+ const listSpy = vi.fn().mockResolvedValue([
992
+ { uid: "prs_known", type: "person", slug: "joey-muller", name: "Joey Muller" },
993
+ ]);
994
+ const vaultClient = makeVaultClient({
995
+ listMyPersonEntities: listSpy,
996
+ findCompanyBySlug: vi.fn().mockResolvedValue(null),
997
+ createCompanyEntity: vi.fn().mockResolvedValue(entity),
998
+ });
999
+
1000
+ const result = await provisionCompany({
1001
+ slug: "indigo",
1002
+ hqRoot: tmpRoot,
1003
+ vaultApiUrl,
1004
+ vaultClient,
1005
+ resolveAccessToken: async () => accessToken,
1006
+ runInitialSync: async () => ({ filesUploaded: 0, bytesUploaded: 0 }),
1007
+ log: () => {},
1008
+ });
1009
+
1010
+ expect(listSpy).toHaveBeenCalledOnce();
1011
+ expect(result.ok).toBe(true);
1012
+ expect(result.cloud_uid).toBe("cmp_OK");
1013
+ });
723
1014
  });
@@ -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,80 @@ 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
+ // Must be an actual directory, not a stray file. fs.existsSync returns true
306
+ // for regular files too — without this guard, auto-insert would fire on a
307
+ // file at `companies/<slug>`, provisionCompany would then create the vault
308
+ // entity and patch manifest.yaml before writeCompanyConfig's `mkdir -p .hq`
309
+ // exploded with ENOTDIR. statSync swallows the not-found case so a missing
310
+ // path is treated the same as before (no auto-insert).
311
+ let dirStat: fs.Stats;
312
+ try {
313
+ dirStat = fs.statSync(dir);
314
+ } catch {
315
+ return;
316
+ }
317
+ if (!dirStat.isDirectory()) return;
318
+ const raw = fs.readFileSync(mPath, "utf-8");
319
+ let parsed: unknown;
320
+ try {
321
+ parsed = yaml.load(raw);
322
+ } catch {
323
+ return;
324
+ }
325
+ if (
326
+ !parsed ||
327
+ typeof parsed !== "object" ||
328
+ !("companies" in parsed) ||
329
+ typeof (parsed as ManifestDoc).companies !== "object"
330
+ ) {
331
+ return;
332
+ }
333
+ const doc = parsed as ManifestDoc;
334
+ if (!doc.companies) doc.companies = {};
335
+ if (doc.companies[slug] !== undefined) return;
336
+ doc.companies[slug] = {};
337
+ const dump = yaml.dump(doc, { lineWidth: -1, noRefs: true });
338
+ const tmp = `${mPath}.tmp.${process.pid}`;
339
+ fs.writeFileSync(tmp, dump);
340
+ fs.renameSync(tmp, mPath);
341
+ }
342
+
249
343
  // ── Manifest patching (atomic) ───────────────────────────────────────────────
250
344
 
251
345
  /**
@@ -345,6 +439,20 @@ export function createDefaultVaultClient(
345
439
  Authorization: `Bearer ${accessToken}`,
346
440
  };
347
441
  return {
442
+ async listMyPersonEntities(): Promise<VaultEntity[]> {
443
+ const url = `${apiUrl.replace(/\/$/, "")}/entity/by-type/person`;
444
+ const res = await fetch(url, { method: "GET", headers });
445
+ if (res.status === 404) return [];
446
+ if (!res.ok) {
447
+ const body = await safeBody(res);
448
+ throw new ProvisionError(
449
+ 1,
450
+ `Vault GET /entity/by-type/person failed: ${res.status} ${res.statusText} — ${body}`,
451
+ );
452
+ }
453
+ const data = (await res.json()) as { entities?: VaultEntity[] };
454
+ return data.entities ?? [];
455
+ },
348
456
  async findCompanyBySlug(slug: string): Promise<VaultEntity | null> {
349
457
  const url = `${apiUrl.replace(/\/$/, "")}/entity/by-slug/company/${encodeURIComponent(
350
458
  slug,
@@ -450,8 +558,13 @@ export async function provisionCompany(
450
558
  ): Promise<ProvisionResult> {
451
559
  const log = options.log ?? ((msg: string) => process.stderr.write(`[hq cloud provision] ${msg}\n`));
452
560
 
453
- // Step 1+2+3: validate slug, manifest, dir
561
+ // Step 1+2+3: validate slug, auto-heal a folder-only manifest, then validate
562
+ // the resulting manifest + dir. The auto-heal is provision-specific: it
563
+ // inserts an empty entry when companies/<slug>/ exists on disk but the slug
564
+ // is missing from manifest.yaml. validateManifestAndDir stays strict so
565
+ // other callers (cloud-demote) keep their typo-catching guard.
454
566
  validateSlug(options.slug);
567
+ ensureManifestEntryForProvision(options.hqRoot, options.slug);
455
568
  validateManifestAndDir(options.hqRoot, options.slug);
456
569
  log(`validated slug=${options.slug}`);
457
570
 
@@ -466,6 +579,27 @@ export async function provisionCompany(
466
579
  options.vaultClient ??
467
580
  createDefaultVaultClient(options.vaultApiUrl, accessToken);
468
581
 
582
+ // Pre-flight: caller MUST have a registered person entity. Without one,
583
+ // the initial-sync step at the end of this flow (STS /sts/vend) returns
584
+ // 403 "no person entity" and leaves the operator with a half-built cloud
585
+ // company — vault entity created, S3 bucket provisioned, manifest patched,
586
+ // .hq/config.json written, but nothing actually syncing. This was the
587
+ // primary failure mode in the 2026-05-14 setup-session deep dive
588
+ // (Joey Muller / sum-digital).
589
+ //
590
+ // Failing here, BEFORE any cloud-side resource creation, leaves the operator
591
+ // with no cleanup work — they fix the underlying onboarding gap (run
592
+ // `hq onboard` / sign in with the correct federated identity) and re-run
593
+ // `hq cloud provision company <slug>` cleanly.
594
+ const persons = await vaultClient.listMyPersonEntities();
595
+ if (persons.length === 0) {
596
+ throw new ProvisionError(
597
+ 2,
598
+ '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.)',
599
+ );
600
+ }
601
+ log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
602
+
469
603
  let entity = await vaultClient.findCompanyBySlug(options.slug);
470
604
  let createdEntity = false;
471
605
  if (entity) {