@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pack-install layout tests — guards the boundary between hq-cli and the
|
|
3
|
+
* HQ template's on-disk layout.
|
|
4
|
+
*
|
|
5
|
+
* The HQ template (hq-core / hq-core-staging) ships:
|
|
6
|
+
* - `core/packages/` as the install root for content packs
|
|
7
|
+
* - `core/scripts/scan-packages.sh` as the post-install wirer
|
|
8
|
+
* - NO top-level `modules/` directory under the new layout
|
|
9
|
+
*
|
|
10
|
+
* Earlier `pack-install` wrote to `packages/<name>/` (top-level), called
|
|
11
|
+
* `scripts/scan-packages.sh` (top-level), and appended entries to
|
|
12
|
+
* `modules/modules.yaml` — leaving three orphan trees alongside the real
|
|
13
|
+
* `core/packages/`. This file pins the new contract end-to-end.
|
|
14
|
+
*
|
|
15
|
+
* Uses real `rsync` (POSIX-available on macOS / Linux runners) so the test
|
|
16
|
+
* exercises the same shell-out the production path does — no hand-rolled
|
|
17
|
+
* mocks of payload-copy behavior.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as os from 'os';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import { installToPackages, runScanPackages } from './pack-install.js';
|
|
25
|
+
import type { PackManifest } from '../types.js';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Fixtures
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function mkFakeHq(): string {
|
|
32
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'hq-cli-test-hq-'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mkFakePackPayload(files: Record<string, string>): string {
|
|
36
|
+
const payload = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-cli-test-pack-'));
|
|
37
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
38
|
+
const abs = path.join(payload, rel);
|
|
39
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
40
|
+
fs.writeFileSync(abs, body);
|
|
41
|
+
}
|
|
42
|
+
return payload;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fakeManifest(overrides?: Partial<PackManifest>): PackManifest {
|
|
46
|
+
return {
|
|
47
|
+
name: 'hq-pack-test',
|
|
48
|
+
version: '1.0.0',
|
|
49
|
+
contributes: {},
|
|
50
|
+
...overrides,
|
|
51
|
+
} as PackManifest;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Tests
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('pack-install: install path layout', () => {
|
|
59
|
+
let hqRoot: string;
|
|
60
|
+
let payload: string;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
hqRoot = mkFakeHq();
|
|
64
|
+
payload = mkFakePackPayload({
|
|
65
|
+
'README.md': '# pack readme',
|
|
66
|
+
'package.yaml': 'name: hq-pack-test\nversion: 1.0.0\n',
|
|
67
|
+
'hooks/pre.sh': '#!/bin/sh\necho hi\n',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
73
|
+
fs.rmSync(payload, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---- 1. install destination ---------------------------------------------
|
|
77
|
+
it('installToPackages writes the payload under core/packages/<name>/, NOT top-level packages/', () => {
|
|
78
|
+
const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
79
|
+
|
|
80
|
+
// New layout: core/packages/<name>/
|
|
81
|
+
expect(dest).toBe(path.join(hqRoot, 'core', 'packages', 'hq-pack-test'));
|
|
82
|
+
expect(fs.existsSync(path.join(hqRoot, 'core', 'packages', 'hq-pack-test', 'README.md'))).toBe(
|
|
83
|
+
true,
|
|
84
|
+
);
|
|
85
|
+
expect(
|
|
86
|
+
fs.existsSync(path.join(hqRoot, 'core', 'packages', 'hq-pack-test', 'hooks', 'pre.sh')),
|
|
87
|
+
).toBe(true);
|
|
88
|
+
|
|
89
|
+
// Top-level packages/ must NOT be created — that was the old buggy layout.
|
|
90
|
+
expect(fs.existsSync(path.join(hqRoot, 'packages'))).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---- 2. re-install idempotency -----------------------------------------
|
|
94
|
+
it('installToPackages replaces an existing core/packages/<name> destination cleanly', () => {
|
|
95
|
+
// Seed a stale install with a file the new payload doesn't have. A clean
|
|
96
|
+
// re-install must wipe it — otherwise stale contributions linger and
|
|
97
|
+
// scan-packages.sh wires them back into host paths.
|
|
98
|
+
const dest = path.join(hqRoot, 'core', 'packages', 'hq-pack-test');
|
|
99
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
100
|
+
fs.writeFileSync(path.join(dest, 'STALE-FILE.md'), 'old content');
|
|
101
|
+
|
|
102
|
+
installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
103
|
+
|
|
104
|
+
expect(fs.existsSync(path.join(dest, 'README.md'))).toBe(true); // new content
|
|
105
|
+
expect(fs.existsSync(path.join(dest, 'STALE-FILE.md'))).toBe(false); // wiped
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---- 3. scan-packages.sh resolution -------------------------------------
|
|
109
|
+
it('runScanPackages invokes core/scripts/scan-packages.sh when present', () => {
|
|
110
|
+
const scriptDir = path.join(hqRoot, 'core', 'scripts');
|
|
111
|
+
fs.mkdirSync(scriptDir, { recursive: true });
|
|
112
|
+
const sentinel = path.join(hqRoot, '.scan-ran');
|
|
113
|
+
// Self-attesting script: writes a sentinel file when invoked. Lets us
|
|
114
|
+
// verify path resolution without spying on spawnSync.
|
|
115
|
+
fs.writeFileSync(
|
|
116
|
+
path.join(scriptDir, 'scan-packages.sh'),
|
|
117
|
+
`#!/bin/sh\ntouch "${sentinel}"\n`,
|
|
118
|
+
{ mode: 0o755 },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
runScanPackages(hqRoot);
|
|
122
|
+
|
|
123
|
+
expect(fs.existsSync(sentinel)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('runScanPackages skips with a dim warning when core/scripts/scan-packages.sh is absent', () => {
|
|
127
|
+
// No script anywhere — runScanPackages must not throw, must not invoke
|
|
128
|
+
// the old top-level scripts/scan-packages.sh either.
|
|
129
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
130
|
+
|
|
131
|
+
expect(() => runScanPackages(hqRoot)).not.toThrow();
|
|
132
|
+
|
|
133
|
+
expect(logSpy).toHaveBeenCalled();
|
|
134
|
+
// The "skipping auto-wire" message goes through chalk.dim, so just match
|
|
135
|
+
// the human-readable substring rather than the ANSI sequence.
|
|
136
|
+
const allLogs = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
137
|
+
expect(allLogs).toMatch(/scan-packages\.sh not present/);
|
|
138
|
+
logSpy.mockRestore();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---- 4. no modules/ directory -------------------------------------------
|
|
142
|
+
it('regression: installing a pack does not create top-level modules/ or modules.yaml', () => {
|
|
143
|
+
installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
144
|
+
|
|
145
|
+
expect(fs.existsSync(path.join(hqRoot, 'modules'))).toBe(false);
|
|
146
|
+
expect(fs.existsSync(path.join(hqRoot, 'modules.yaml'))).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -26,9 +26,12 @@
|
|
|
26
26
|
* 3. Parse + validate package.yaml (10 checks from spec)
|
|
27
27
|
* 4. Evaluate `conditional` predicate — skip if exits non-zero
|
|
28
28
|
* 5. Confirm hooks if `contributes.hooks` non-empty (unless --allow-hooks)
|
|
29
|
-
* 6. Move into packages/{name}/
|
|
30
|
-
* 7.
|
|
31
|
-
*
|
|
29
|
+
* 6. Move into core/packages/{name}/
|
|
30
|
+
* 7. Run core/scripts/scan-packages.sh to wire contributions into host paths
|
|
31
|
+
*
|
|
32
|
+
* Installed packs are tracked by filesystem presence — there's no separate
|
|
33
|
+
* registry file under the v12 layout. (`hq update <pack>` re-resolves source
|
|
34
|
+
* from each pack's package.yaml; rationale lives in the layout-fix PR.)
|
|
32
35
|
*/
|
|
33
36
|
|
|
34
37
|
import * as fs from 'fs';
|
|
@@ -42,17 +45,8 @@ import chalk from 'chalk';
|
|
|
42
45
|
import semverSatisfies from 'semver/functions/satisfies.js';
|
|
43
46
|
import semverValid from 'semver/functions/valid.js';
|
|
44
47
|
import semverValidRange from 'semver/ranges/valid.js';
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
readManifest,
|
|
48
|
-
writeManifest,
|
|
49
|
-
} from '../utils/manifest.js';
|
|
50
|
-
import type {
|
|
51
|
-
PackManifest,
|
|
52
|
-
PackModuleDefinition,
|
|
53
|
-
ModulesManifest,
|
|
54
|
-
PackContributeKey,
|
|
55
|
-
} from '../types.js';
|
|
48
|
+
import { findHqRoot } from '../utils/manifest.js';
|
|
49
|
+
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
56
50
|
|
|
57
51
|
// ---------------------------------------------------------------------------
|
|
58
52
|
// Source classification
|
|
@@ -519,15 +513,28 @@ function evalConditional(expr: string): boolean {
|
|
|
519
513
|
}
|
|
520
514
|
|
|
521
515
|
// ---------------------------------------------------------------------------
|
|
522
|
-
// Move into packages/ +
|
|
516
|
+
// Move into core/packages/ + run core/scripts/scan-packages.sh
|
|
523
517
|
// ---------------------------------------------------------------------------
|
|
524
518
|
|
|
525
|
-
|
|
519
|
+
/**
|
|
520
|
+
* Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
|
|
521
|
+
* v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
|
|
522
|
+
* `core/packages/` as the canonical pack root; writing to top-level
|
|
523
|
+
* `packages/` would leave an orphan tree alongside the real one.
|
|
524
|
+
*
|
|
525
|
+
* Re-installs replace the existing destination so stale contributions don't
|
|
526
|
+
* linger (the post-install `scan-packages.sh` would otherwise wire them
|
|
527
|
+
* back into host paths).
|
|
528
|
+
*
|
|
529
|
+
* Exported for tests in pack-install.test.ts — see that file for the
|
|
530
|
+
* contract this function pins.
|
|
531
|
+
*/
|
|
532
|
+
export function installToPackages(
|
|
526
533
|
payloadDir: string,
|
|
527
534
|
pkg: PackManifest,
|
|
528
535
|
hqRoot: string
|
|
529
536
|
): string {
|
|
530
|
-
const packagesDir = path.join(hqRoot, 'packages');
|
|
537
|
+
const packagesDir = path.join(hqRoot, 'core', 'packages');
|
|
531
538
|
fs.mkdirSync(packagesDir, { recursive: true });
|
|
532
539
|
const destDir = path.join(packagesDir, pkg.name);
|
|
533
540
|
if (fs.existsSync(destDir)) {
|
|
@@ -540,35 +547,21 @@ function installToPackages(
|
|
|
540
547
|
return destDir;
|
|
541
548
|
}
|
|
542
549
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
strategy: 'package',
|
|
555
|
-
source: fetched.resolvedSource,
|
|
556
|
-
version: pkg.version,
|
|
557
|
-
installed_at: path.posix.join('packages', pkg.name),
|
|
558
|
-
installed_at_iso: new Date().toISOString(),
|
|
559
|
-
access: pkg.access === 'public' ? 'public' : undefined,
|
|
560
|
-
};
|
|
561
|
-
if (fetched.resolvedSha) entry.resolved_sha = fetched.resolvedSha;
|
|
562
|
-
manifest.modules.push(entry);
|
|
563
|
-
writeManifest(hqRoot, manifest);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function runScanPackages(hqRoot: string): void {
|
|
567
|
-
const script = path.join(hqRoot, 'scripts', 'scan-packages.sh');
|
|
550
|
+
/**
|
|
551
|
+
* Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
|
|
552
|
+
* pack's contributions into the host paths (skills, hooks, policies, etc.).
|
|
553
|
+
* Skipping with a dim warning if the script is missing keeps fresh HQs (or
|
|
554
|
+
* older templates) usable — the next session start picks them up via its
|
|
555
|
+
* own scan.
|
|
556
|
+
*
|
|
557
|
+
* Exported for tests.
|
|
558
|
+
*/
|
|
559
|
+
export function runScanPackages(hqRoot: string): void {
|
|
560
|
+
const script = path.join(hqRoot, 'core', 'scripts', 'scan-packages.sh');
|
|
568
561
|
if (!fs.existsSync(script)) {
|
|
569
562
|
console.log(
|
|
570
563
|
chalk.dim(
|
|
571
|
-
` (scripts/scan-packages.sh not present — skipping auto-wire; ` +
|
|
564
|
+
` (core/scripts/scan-packages.sh not present — skipping auto-wire; ` +
|
|
572
565
|
`will run on next session start)`
|
|
573
566
|
)
|
|
574
567
|
);
|
|
@@ -647,7 +640,12 @@ export async function installPack(
|
|
|
647
640
|
}
|
|
648
641
|
|
|
649
642
|
const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
|
|
650
|
-
|
|
643
|
+
// Under the v12 HQ layout, packs live at `core/packages/<name>/` and are
|
|
644
|
+
// tracked by filesystem presence alone — no `modules.yaml` write. That
|
|
645
|
+
// removes the side effect that created a top-level `modules/` directory
|
|
646
|
+
// alongside the canonical `core/`. `hq update <pack>` will re-resolve a
|
|
647
|
+
// pack's source from its on-disk package.yaml or prompt for it, but that
|
|
648
|
+
// tradeoff is intentional — see the layout-fix PR for rationale.
|
|
651
649
|
runScanPackages(hqRoot);
|
|
652
650
|
|
|
653
651
|
console.log(
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
maybeWarnNewVersion,
|
|
35
35
|
refreshVersionCache,
|
|
36
36
|
} from "./utils/version-check.js";
|
|
37
|
+
import { CLI_VERSION } from "./cli-version.js";
|
|
37
38
|
|
|
38
39
|
// Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes the pipe early.
|
|
39
40
|
const onPipeError = (err: NodeJS.ErrnoException): void => {
|
|
@@ -53,7 +54,7 @@ const program = new Command();
|
|
|
53
54
|
program
|
|
54
55
|
.name("hq")
|
|
55
56
|
.description("HQ management CLI — modules, packages, and cloud sync")
|
|
56
|
-
.version(
|
|
57
|
+
.version(CLI_VERSION);
|
|
57
58
|
|
|
58
59
|
// Module management subcommand group
|
|
59
60
|
const modulesCmd = program
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for resolveDefaultHqRoot — Cognito session helper bits that
|
|
3
|
+
* don't need a live JWT or vault round-trip.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { resolveDefaultHqRoot } from "./cognito-session.js";
|
|
12
|
+
|
|
13
|
+
describe("resolveDefaultHqRoot", () => {
|
|
14
|
+
let tmpRoot: string;
|
|
15
|
+
let origCwd: string;
|
|
16
|
+
let origEnv: string | undefined;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tmpRoot = mkdtempSync(join(tmpdir(), "hq-cli-resolveroot-"));
|
|
20
|
+
origCwd = process.cwd();
|
|
21
|
+
origEnv = process.env.HQ_ROOT;
|
|
22
|
+
delete process.env.HQ_ROOT;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.chdir(origCwd);
|
|
27
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
28
|
+
if (origEnv === undefined) delete process.env.HQ_ROOT;
|
|
29
|
+
else process.env.HQ_ROOT = origEnv;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("priority 1: honors $HQ_ROOT env var (resolved to absolute path)", () => {
|
|
33
|
+
const explicit = join(tmpRoot, "explicit-target");
|
|
34
|
+
mkdirSync(explicit, { recursive: true });
|
|
35
|
+
process.env.HQ_ROOT = explicit;
|
|
36
|
+
|
|
37
|
+
expect(resolveDefaultHqRoot()).toBe(explicit);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("priority 2: walks up from cwd to the nearest dir containing core.yaml AND companies/", () => {
|
|
41
|
+
const hqDir = join(tmpRoot, "Documents", "coding-projects", "hq");
|
|
42
|
+
const nested = join(hqDir, "companies", "sum-digital");
|
|
43
|
+
mkdirSync(nested, { recursive: true });
|
|
44
|
+
writeFileSync(join(hqDir, "core.yaml"), "version: 14.1.1\n");
|
|
45
|
+
// companies/ already exists (we mkdir'd nested inside it)
|
|
46
|
+
|
|
47
|
+
process.chdir(nested);
|
|
48
|
+
expect(resolveDefaultHqRoot()).toBe(hqDir);
|
|
49
|
+
|
|
50
|
+
process.chdir(hqDir);
|
|
51
|
+
expect(resolveDefaultHqRoot()).toBe(hqDir);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("priority 2: SKIPS a nested core/core.yaml without a sibling companies/ dir (Codex P2 on hq#146)", () => {
|
|
55
|
+
// Recreates the live HQ layout: a real root has core.yaml + companies/,
|
|
56
|
+
// and the synced core/ subtree contains its own core.yaml (the template
|
|
57
|
+
// version-source-of-truth) but NO companies/. Single-marker detection
|
|
58
|
+
// would stop at <hqRoot>/core/ and silently miss the real content.
|
|
59
|
+
const hqDir = join(tmpRoot, "Documents", "coding-projects", "hq");
|
|
60
|
+
const innerCore = join(hqDir, "core");
|
|
61
|
+
const cwdInsideCore = join(innerCore, "knowledge");
|
|
62
|
+
mkdirSync(cwdInsideCore, { recursive: true });
|
|
63
|
+
mkdirSync(join(hqDir, "companies"), { recursive: true });
|
|
64
|
+
writeFileSync(join(hqDir, "core.yaml"), "version: 14.1.1\n");
|
|
65
|
+
writeFileSync(join(innerCore, "core.yaml"), "template-version: 14.1.1\n");
|
|
66
|
+
|
|
67
|
+
process.chdir(cwdInsideCore);
|
|
68
|
+
// Should walk PAST the inner core/core.yaml (no companies/ sibling) and
|
|
69
|
+
// resolve to the real hqDir.
|
|
70
|
+
expect(resolveDefaultHqRoot()).toBe(hqDir);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("priority 3: falls back when neither $HQ_ROOT nor an HQ-root marker pair are found", () => {
|
|
74
|
+
const stranded = join(tmpRoot, "stranded");
|
|
75
|
+
mkdirSync(stranded, { recursive: true });
|
|
76
|
+
process.chdir(stranded);
|
|
77
|
+
|
|
78
|
+
const result = resolveDefaultHqRoot();
|
|
79
|
+
// Walks up to filesystem root, no core.yaml+companies/ pair found, falls
|
|
80
|
+
// back to ~/hq. We don't assert the exact value (depends on the test
|
|
81
|
+
// runner's $HOME) but the result should NOT be the stranded dir.
|
|
82
|
+
expect(result).not.toBe(stranded);
|
|
83
|
+
expect(result.endsWith("/hq")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("priority 3: a dir with only core.yaml (no companies/) is NOT a valid HQ root", () => {
|
|
87
|
+
// core.yaml present but no companies/ sibling → fall through to ~/hq.
|
|
88
|
+
// This is the synced core/ subtree's signature.
|
|
89
|
+
const lonelyCoreYaml = join(tmpRoot, "lonely");
|
|
90
|
+
mkdirSync(lonelyCoreYaml, { recursive: true });
|
|
91
|
+
writeFileSync(join(lonelyCoreYaml, "core.yaml"), "");
|
|
92
|
+
process.chdir(lonelyCoreYaml);
|
|
93
|
+
|
|
94
|
+
const result = resolveDefaultHqRoot();
|
|
95
|
+
expect(result).not.toBe(lonelyCoreYaml);
|
|
96
|
+
expect(result.endsWith("/hq")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("$HQ_ROOT wins over walking-up resolution", () => {
|
|
100
|
+
const walkable = join(tmpRoot, "walkable");
|
|
101
|
+
const explicit = join(tmpRoot, "explicit");
|
|
102
|
+
mkdirSync(join(walkable, "nested"), { recursive: true });
|
|
103
|
+
mkdirSync(join(walkable, "companies"), { recursive: true });
|
|
104
|
+
mkdirSync(explicit, { recursive: true });
|
|
105
|
+
writeFileSync(join(walkable, "core.yaml"), "");
|
|
106
|
+
|
|
107
|
+
process.chdir(join(walkable, "nested"));
|
|
108
|
+
process.env.HQ_ROOT = explicit;
|
|
109
|
+
|
|
110
|
+
expect(resolveDefaultHqRoot()).toBe(explicit);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* HQ_VAULT_API_URL — vault-service API Gateway URL
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import * as fs from "fs";
|
|
22
23
|
import * as os from "os";
|
|
23
24
|
import * as path from "path";
|
|
24
25
|
import chalk from "chalk";
|
|
@@ -52,7 +53,51 @@ export const DEFAULT_COGNITO: CognitoAuthConfig = {
|
|
|
52
53
|
export const DEFAULT_VAULT_API_URL =
|
|
53
54
|
process.env.HQ_VAULT_API_URL ?? "https://hqapi.getindigo.ai";
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the default HQ tree root for cloud-aware subcommands.
|
|
58
|
+
*
|
|
59
|
+
* Priority order:
|
|
60
|
+
* 1. `$HQ_ROOT` env var (explicit user override)
|
|
61
|
+
* 2. Walk up from `process.cwd()` to the nearest dir containing BOTH a
|
|
62
|
+
* `core.yaml` file AND a `companies/` directory (root-unique marker
|
|
63
|
+
* pair — see note below).
|
|
64
|
+
* 3. Fall back to `~/hq` (the historical default)
|
|
65
|
+
*
|
|
66
|
+
* Why both markers?
|
|
67
|
+
* The HQ root has `core.yaml` AND a sibling `companies/` directory. The
|
|
68
|
+
* synced `core/` subtree (which is itself part of the root's personal-vault
|
|
69
|
+
* scope) ALSO contains a `core.yaml` (the template's version-source-of-
|
|
70
|
+
* truth), but does NOT contain `companies/`. Single-marker `core.yaml`
|
|
71
|
+
* detection would stop at `<hqRoot>/core/` when the CLI is launched from
|
|
72
|
+
* somewhere inside that subtree, and downstream `companies/` lookups would
|
|
73
|
+
* silently miss the real content. Requiring `companies/` as well guarantees
|
|
74
|
+
* we resolve to the actual HQ root (Codex P2 on hq#146).
|
|
75
|
+
*
|
|
76
|
+
* Evaluated once at module load — commander.js `.option()` callers pin the
|
|
77
|
+
* value at registration time, which matches the user's actual cwd at process
|
|
78
|
+
* start. Re-importable as a function for tests and command-time resolution.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveDefaultHqRoot(): string {
|
|
81
|
+
if (process.env.HQ_ROOT) return path.resolve(process.env.HQ_ROOT);
|
|
82
|
+
let cur = path.resolve(process.cwd());
|
|
83
|
+
while (cur !== path.dirname(cur)) {
|
|
84
|
+
if (isHqRoot(cur)) return cur;
|
|
85
|
+
cur = path.dirname(cur);
|
|
86
|
+
}
|
|
87
|
+
return path.join(os.homedir(), "hq");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True iff `dir` looks like an HQ root (has core.yaml + companies/ dir). */
|
|
91
|
+
function isHqRoot(dir: string): boolean {
|
|
92
|
+
if (!fs.existsSync(path.join(dir, "core.yaml"))) return false;
|
|
93
|
+
try {
|
|
94
|
+
return fs.statSync(path.join(dir, "companies")).isDirectory();
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const DEFAULT_HQ_ROOT = resolveDefaultHqRoot();
|
|
56
101
|
|
|
57
102
|
/**
|
|
58
103
|
* Return a non-expired Cognito access token, refreshing or browser-logging-in
|