@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.
- package/CHANGELOG.md +88 -0
- package/dist/cli-version.d.ts +1 -1
- package/dist/cli-version.js +9 -3
- package/dist/commands/cloud-provision.d.ts +40 -3
- package/dist/commands/cloud-provision.js +118 -8
- package/dist/commands/pack-install.d.ts +31 -3
- package/dist/commands/pack-install.js +43 -31
- package/dist/index.js +4 -3
- package/dist/utils/cognito-session.d.ts +25 -0
- package/dist/utils/cognito-session.js +50 -3
- package/package.json +1 -1
- package/src/cli-version.ts +9 -1
- package/src/commands/cloud-provision.test.ts +275 -0
- package/src/commands/cloud-provision.ts +131 -9
- package/src/commands/pack-install.test.ts +148 -0
- package/src/commands/pack-install.ts +42 -44
- package/src/index.ts +2 -1
- package/src/utils/cognito-session.test.ts +112 -0
- package/src/utils/cognito-session.ts +46 -1
|
@@ -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]="
|
|
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
|
-
|
|
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=
|
|
158
|
+
//# debugId=6a8ac157-0903-513d-b4c5-45fb532b8026
|
package/package.json
CHANGED
package/src/cli-version.ts
CHANGED
|
@@ -1 +1,9 @@
|
|
|
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
|
|
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,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,
|
|
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) {
|