@indigoai-us/hq-cli 5.14.0 → 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.
@@ -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. Append entry to modules.yaml with strategy: package
31
- * 8. Run scan-packages.sh to wire contributions into host paths
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
- findHqRoot,
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/ + update modules.yaml + scan
516
+ // Move into core/packages/ + run core/scripts/scan-packages.sh
523
517
  // ---------------------------------------------------------------------------
524
518
 
525
- function installToPackages(
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
- function updateModulesYaml(
544
- hqRoot: string,
545
- pkg: PackManifest,
546
- fetched: FetchResult
547
- ): void {
548
- const manifest: ModulesManifest =
549
- readManifest(hqRoot) ?? { version: '1', modules: [] };
550
- // Drop any existing entry for this pack (idempotent re-install)
551
- manifest.modules = manifest.modules.filter((m) => m.name !== pkg.name);
552
- const entry: PackModuleDefinition = {
553
- name: pkg.name,
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
- updateModulesYaml(hqRoot, pkg, fetched);
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(
@@ -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
- export const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
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