@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/.github/workflows/ci.yml +21 -0
- package/.github/workflows/publish.yml +86 -0
- package/.github/workflows/scripts/smoke-test-pkg.sh +97 -0
- package/CHANGELOG.md +19 -0
- package/dist/commands/cloud-provision.d.ts +40 -3
- package/dist/commands/cloud-provision.js +131 -8
- package/dist/commands/meetings.d.ts +3 -0
- package/dist/commands/meetings.js +374 -0
- package/dist/commands/pack-install.d.ts +31 -3
- package/dist/commands/pack-install.js +43 -31
- package/dist/index.js +5 -2
- package/dist/utils/cognito-session.d.ts +25 -0
- package/dist/utils/cognito-session.js +50 -3
- package/package.json +3 -2
- package/src/commands/cloud-provision.test.ts +291 -0
- package/src/commands/cloud-provision.ts +143 -9
- package/src/commands/meetings.ts +487 -0
- package/src/commands/pack-install.test.ts +148 -0
- package/src/commands/pack-install.ts +42 -44
- package/src/index.ts +4 -0
- package/src/utils/cognito-session.test.ts +112 -0
- package/src/utils/cognito-session.ts +46 -1
- package/tsconfig.json +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
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
|
-
"
|
|
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
|
|
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,
|
|
152
|
-
*
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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,
|
|
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) {
|