@indigoai-us/hq-cli 5.36.4 → 5.36.6

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.
@@ -22,47 +22,51 @@ import { type CognitoAuthConfig, type ClientInfo, type VaultServiceConfig } from
22
22
  export declare const DEFAULT_COGNITO: CognitoAuthConfig;
23
23
  export declare const DEFAULT_VAULT_API_URL: string;
24
24
  /**
25
- * Resolve the default HQ tree root for cloud-aware subcommands.
25
+ * Resolve the HQ tree root for cloud-aware subcommands (`hq sync`, `hq onboard`,
26
+ * `hq cloud …`, etc.).
26
27
  *
27
- * Priority order:
28
- * 1. `$HQ_ROOT` env var (explicit user override)
29
- * 2. Walk up from `process.cwd()` to the nearest dir containing BOTH a
30
- * `core.yaml` file AND a `companies/` directory (root-unique marker
31
- * pair see note below).
32
- * 3. Fall back to `~/hq` (the historical default)
28
+ * Resolution order — mirrors the menubar/installer resolver
29
+ * (`hq-sync` `util/paths.rs::resolve_hq_folder`) so the CLI targets the SAME
30
+ * root the AppBar syncs, regardless of cwd:
31
+ * 1. `$HQ_ROOT` env var explicit assertion by the caller; short-circuits all.
32
+ * 2. Walk up from `process.cwd()` to the nearest HQ root (working-tree wins:
33
+ * if you're inside an HQ tree, operate on THAT tree). See {@link isHqRoot}.
34
+ * 3. `~/.hq/menubar.json` → `hqPath` — the canonical record written by the
35
+ * installer (≥0.1.28) and the menubar's Settings re-tether. Same field HQ
36
+ * Sync reads first.
37
+ * 4. `~/.hq/config.json` → `hqFolderPath` — legacy installer record.
38
+ * 5. Discovery: scan well-known locations (`~/HQ`, `~/hq`, `~/Documents/HQ`,
39
+ * …) for a folder carrying a valid `core.yaml` signature. Safety net for
40
+ * installs that never wrote the path back to menubar.json.
41
+ * 6. Fall back to `~/hq` (the historical CLI default) — or throw, per
42
+ * `opts.onMissing`.
33
43
  *
34
- * Why both markers?
35
- * The HQ root has `core.yaml` AND a sibling `companies/` directory. The
36
- * synced `core/` subtree (which is itself part of the root's personal-vault
37
- * scope) ALSO contains a `core.yaml` (the template's version-source-of-
38
- * truth), but does NOT contain `companies/`. Single-marker `core.yaml`
39
- * detection would stop at `<hqRoot>/core/` when the CLI is launched from
40
- * somewhere inside that subtree, and downstream `companies/` lookups would
41
- * silently miss the real content. Requiring `companies/` as well guarantees
42
- * we resolve to the actual HQ root (Codex P2 on hq#146).
44
+ * Why tiers 3–5 exist:
45
+ * Before this, the CLI only knew `$HQ_ROOT` and the cwd walk-up. Run from any
46
+ * cwd OUTSIDE the HQ tree with `$HQ_ROOT` unset, it fell straight to `~/hq` —
47
+ * so a user whose HQ lives anywhere else (e.g. `~/Desktop/HQ`, as the AppBar
48
+ * syncs it) silently got a brand-new `~/hq` stub and a full re-sync into it
49
+ * (split-brain). Reading the installer/menubar records the way HQ Sync does
50
+ * closes that gap (feedback_3967e294).
43
51
  *
44
- * Evaluated once at module load commander.js `.option()` callers pin the
45
- * value at registration time, which matches the user's actual cwd at process
46
- * start. Re-importable as a function for tests and command-time resolution.
47
- */
48
- /**
49
- * Resolve the HQ root directory.
50
- *
51
- * Resolution order:
52
- * 1. $HQ_ROOT env var (treated as an explicit assertion by the caller)
53
- * 2. Walk up from cwd looking for `core.yaml` AND `companies/` siblings
54
- * 3. Fall back to `~/hq` (or throw, per `opts.onMissing`)
52
+ * Why both markers in the walk-up (tier 2)?
53
+ * An HQ root has a `core.yaml` (canonical `core/core.yaml` since hq-core v14,
54
+ * legacy `<root>/core.yaml` before) AND a sibling `companies/` directory. The
55
+ * synced `core/` subtree ALSO contains a `core.yaml` (the template version
56
+ * source-of-truth) but NO `companies/`. Requiring `companies/` as well stops
57
+ * the walk from latching onto `<hqRoot>/core/` (Codex P2 on hq#146).
55
58
  *
56
- * `opts.onMissing` controls the third arm:
57
- * - `'fallback'` (default) — return `~/hq` if no HQ root is found above cwd.
58
- * This preserves the module-load contract of `DEFAULT_HQ_ROOT`, which
59
- * several commander.js `.option()` callers pin at registration time.
60
- * - `'throw'` — throw with a user-actionable error. Used by module-management
61
- * commands (pkg-install, pkg-remove, pkg-list, pkg-update, team-sync) where
62
- * a silent default-path miss would silently target the wrong directory.
59
+ * `opts.onMissing` controls the final arm:
60
+ * - `'fallback'` (default) — return `~/hq` if nothing resolves. Preserves the
61
+ * module-load contract of `DEFAULT_HQ_ROOT`, pinned by commander.js
62
+ * `.option()` callers at registration time.
63
+ * - `'throw'` — throw a user-actionable error. Used by module-management
64
+ * commands (pkg-*, team-sync) where a silent default-path miss would
65
+ * target the wrong directory.
63
66
  *
64
- * `$HQ_ROOT` short-circuits both arms if the env var is set, it's used
65
- * as-is regardless of `onMissing`.
67
+ * Evaluated once at module load for `DEFAULT_HQ_ROOT`; the installer/menubar
68
+ * records and cwd are both fixed at process start, so a one-shot read is sound.
69
+ * Re-importable as a function for command-time resolution and tests.
66
70
  */
67
71
  export declare function resolveDefaultHqRoot(opts?: {
68
72
  onMissing?: "throw" | "fallback";
@@ -19,10 +19,11 @@
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]="cd9690e0-c7b0-5e26-a3ee-7f7bc98a0fe2")}catch(e){}}();
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]="984bafe3-242a-5322-a2fe-65261b24ee0b")}catch(e){}}();
23
23
  import * as fs from "fs";
24
24
  import * as os from "os";
25
25
  import * as path from "path";
26
+ import * as yaml from "js-yaml";
26
27
  import chalk from "chalk";
27
28
  import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, } from "@indigoai-us/hq-cloud";
28
29
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
@@ -44,66 +45,174 @@ export const DEFAULT_COGNITO = {
44
45
  };
45
46
  export const DEFAULT_VAULT_API_URL = process.env.HQ_VAULT_API_URL ?? "https://hqapi.getindigo.ai";
46
47
  /**
47
- * Resolve the default HQ tree root for cloud-aware subcommands.
48
+ * Resolve the HQ tree root for cloud-aware subcommands (`hq sync`, `hq onboard`,
49
+ * `hq cloud …`, etc.).
48
50
  *
49
- * Priority order:
50
- * 1. `$HQ_ROOT` env var (explicit user override)
51
- * 2. Walk up from `process.cwd()` to the nearest dir containing BOTH a
52
- * `core.yaml` file AND a `companies/` directory (root-unique marker
53
- * pair see note below).
54
- * 3. Fall back to `~/hq` (the historical default)
51
+ * Resolution order — mirrors the menubar/installer resolver
52
+ * (`hq-sync` `util/paths.rs::resolve_hq_folder`) so the CLI targets the SAME
53
+ * root the AppBar syncs, regardless of cwd:
54
+ * 1. `$HQ_ROOT` env var explicit assertion by the caller; short-circuits all.
55
+ * 2. Walk up from `process.cwd()` to the nearest HQ root (working-tree wins:
56
+ * if you're inside an HQ tree, operate on THAT tree). See {@link isHqRoot}.
57
+ * 3. `~/.hq/menubar.json` → `hqPath` — the canonical record written by the
58
+ * installer (≥0.1.28) and the menubar's Settings re-tether. Same field HQ
59
+ * Sync reads first.
60
+ * 4. `~/.hq/config.json` → `hqFolderPath` — legacy installer record.
61
+ * 5. Discovery: scan well-known locations (`~/HQ`, `~/hq`, `~/Documents/HQ`,
62
+ * …) for a folder carrying a valid `core.yaml` signature. Safety net for
63
+ * installs that never wrote the path back to menubar.json.
64
+ * 6. Fall back to `~/hq` (the historical CLI default) — or throw, per
65
+ * `opts.onMissing`.
55
66
  *
56
- * Why both markers?
57
- * The HQ root has `core.yaml` AND a sibling `companies/` directory. The
58
- * synced `core/` subtree (which is itself part of the root's personal-vault
59
- * scope) ALSO contains a `core.yaml` (the template's version-source-of-
60
- * truth), but does NOT contain `companies/`. Single-marker `core.yaml`
61
- * detection would stop at `<hqRoot>/core/` when the CLI is launched from
62
- * somewhere inside that subtree, and downstream `companies/` lookups would
63
- * silently miss the real content. Requiring `companies/` as well guarantees
64
- * we resolve to the actual HQ root (Codex P2 on hq#146).
67
+ * Why tiers 3–5 exist:
68
+ * Before this, the CLI only knew `$HQ_ROOT` and the cwd walk-up. Run from any
69
+ * cwd OUTSIDE the HQ tree with `$HQ_ROOT` unset, it fell straight to `~/hq` —
70
+ * so a user whose HQ lives anywhere else (e.g. `~/Desktop/HQ`, as the AppBar
71
+ * syncs it) silently got a brand-new `~/hq` stub and a full re-sync into it
72
+ * (split-brain). Reading the installer/menubar records the way HQ Sync does
73
+ * closes that gap (feedback_3967e294).
65
74
  *
66
- * Evaluated once at module load commander.js `.option()` callers pin the
67
- * value at registration time, which matches the user's actual cwd at process
68
- * start. Re-importable as a function for tests and command-time resolution.
69
- */
70
- /**
71
- * Resolve the HQ root directory.
75
+ * Why both markers in the walk-up (tier 2)?
76
+ * An HQ root has a `core.yaml` (canonical `core/core.yaml` since hq-core v14,
77
+ * legacy `<root>/core.yaml` before) AND a sibling `companies/` directory. The
78
+ * synced `core/` subtree ALSO contains a `core.yaml` (the template version
79
+ * source-of-truth) but NO `companies/`. Requiring `companies/` as well stops
80
+ * the walk from latching onto `<hqRoot>/core/` (Codex P2 on hq#146).
72
81
  *
73
- * Resolution order:
74
- * 1. $HQ_ROOT env var (treated as an explicit assertion by the caller)
75
- * 2. Walk up from cwd looking for `core.yaml` AND `companies/` siblings
76
- * 3. Fall back to `~/hq` (or throw, per `opts.onMissing`)
82
+ * `opts.onMissing` controls the final arm:
83
+ * - `'fallback'` (default) return `~/hq` if nothing resolves. Preserves the
84
+ * module-load contract of `DEFAULT_HQ_ROOT`, pinned by commander.js
85
+ * `.option()` callers at registration time.
86
+ * - `'throw'` — throw a user-actionable error. Used by module-management
87
+ * commands (pkg-*, team-sync) where a silent default-path miss would
88
+ * target the wrong directory.
77
89
  *
78
- * `opts.onMissing` controls the third arm:
79
- * - `'fallback'` (default) return `~/hq` if no HQ root is found above cwd.
80
- * This preserves the module-load contract of `DEFAULT_HQ_ROOT`, which
81
- * several commander.js `.option()` callers pin at registration time.
82
- * - `'throw'` — throw with a user-actionable error. Used by module-management
83
- * commands (pkg-install, pkg-remove, pkg-list, pkg-update, team-sync) where
84
- * a silent default-path miss would silently target the wrong directory.
85
- *
86
- * `$HQ_ROOT` short-circuits both arms — if the env var is set, it's used
87
- * as-is regardless of `onMissing`.
90
+ * Evaluated once at module load for `DEFAULT_HQ_ROOT`; the installer/menubar
91
+ * records and cwd are both fixed at process start, so a one-shot read is sound.
92
+ * Re-importable as a function for command-time resolution and tests.
88
93
  */
89
94
  export function resolveDefaultHqRoot(opts = {}) {
95
+ // Tier 1: explicit override.
90
96
  if (process.env.HQ_ROOT)
91
97
  return path.resolve(process.env.HQ_ROOT);
98
+ // Tier 2: working-tree walk-up — operate on the HQ tree you're inside.
92
99
  let cur = path.resolve(process.cwd());
93
100
  while (cur !== path.dirname(cur)) {
94
101
  if (isHqRoot(cur))
95
102
  return cur;
96
103
  cur = path.dirname(cur);
97
104
  }
105
+ // Tiers 3–4: the installer/menubar's canonical "where is HQ" records.
106
+ const fromConfig = readHqRootFromHqConfig(path.join(os.homedir(), ".hq"));
107
+ if (fromConfig)
108
+ return path.resolve(fromConfig);
109
+ // Tier 5: discover a valid HQ root in the well-known locations.
110
+ const discovered = discoverHqRootViaCoreYaml();
111
+ if (discovered)
112
+ return discovered;
113
+ // Tier 6: nothing resolved.
98
114
  if (opts.onMissing === "throw") {
99
115
  throw new Error("Could not find HQ root. Run this command from within your HQ directory " +
100
- "(must contain core.yaml AND a companies/ subdirectory), or set $HQ_ROOT.");
116
+ "(must contain core.yaml AND a companies/ subdirectory), set $HQ_ROOT, " +
117
+ "or install HQ via the installer/menubar so ~/.hq/menubar.json records " +
118
+ "its location.");
101
119
  }
102
120
  return path.join(os.homedir(), "hq");
103
121
  }
104
- /** True iff `dir` looks like an HQ root (has core.yaml + companies/ dir). */
122
+ /**
123
+ * Read the configured HQ root from the installer/menubar records under
124
+ * `hqConfigDir` (`~/.hq`). Mirrors HQ Sync's precedence:
125
+ * 1. `menubar.json` → `hqPath` (canonical; written by installer ≥0.1.28 and
126
+ * the menubar Settings re-tether)
127
+ * 2. `config.json` → `hqFolderPath` (legacy installer flows)
128
+ * Returns the first non-empty string, or undefined. Best-effort: a missing or
129
+ * malformed file is treated as "not configured" (never throws).
130
+ */
131
+ function readHqRootFromHqConfig(hqConfigDir) {
132
+ const fromMenubar = readJsonStringField(path.join(hqConfigDir, "menubar.json"), "hqPath");
133
+ if (fromMenubar)
134
+ return fromMenubar;
135
+ return readJsonStringField(path.join(hqConfigDir, "config.json"), "hqFolderPath");
136
+ }
137
+ /** Read a top-level string field from a JSON file, or undefined on any miss. */
138
+ function readJsonStringField(file, field) {
139
+ try {
140
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
141
+ const value = parsed[field];
142
+ return typeof value === "string" && value.length > 0 ? value : undefined;
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ }
148
+ /** Well-known parent locations an HQ tree commonly lives in (mirrors hq-sync). */
149
+ function hqDiscoveryCandidates() {
150
+ const home = os.homedir();
151
+ return [
152
+ path.join(home, "HQ"),
153
+ path.join(home, "hq"),
154
+ path.join(home, "Documents", "HQ"),
155
+ path.join(home, "Documents", "hq"),
156
+ path.join(home, "Desktop", "HQ"),
157
+ path.join(home, "Desktop", "hq"),
158
+ ];
159
+ }
160
+ /**
161
+ * Scan the well-known locations for a folder carrying a valid `core.yaml`
162
+ * signature. First match wins; returns undefined if none qualify. Cheap — a
163
+ * few stats plus one small YAML parse on a hit.
164
+ */
165
+ function discoverHqRootViaCoreYaml() {
166
+ return hqDiscoveryCandidates().find(isValidHqRootSignature);
167
+ }
168
+ /**
169
+ * True iff `dir` carries a valid hq-core `core.yaml` — canonical
170
+ * (`<dir>/core/core.yaml`, hq-core ≥v14) or legacy (`<dir>/core.yaml`) — that
171
+ * parses as YAML and has BOTH the `version` and `hqVersion` fields. The dual
172
+ * field check (matching hq-sync's `is_valid_hq_root`) stops a stray `core.yaml`
173
+ * from another tool from false-matching.
174
+ */
175
+ function isValidHqRootSignature(dir) {
176
+ const canonical = path.join(dir, "core", "core.yaml");
177
+ const legacy = path.join(dir, "core.yaml");
178
+ let file;
179
+ if (fileExists(canonical))
180
+ file = canonical;
181
+ else if (fileExists(legacy))
182
+ file = legacy;
183
+ else
184
+ return false;
185
+ try {
186
+ const parsed = yaml.load(fs.readFileSync(file, "utf-8"));
187
+ return (!!parsed &&
188
+ typeof parsed === "object" &&
189
+ "version" in parsed &&
190
+ "hqVersion" in parsed);
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ }
196
+ /** True iff `p` exists and is a regular file. */
197
+ function fileExists(p) {
198
+ try {
199
+ return fs.statSync(p).isFile();
200
+ }
201
+ catch {
202
+ return false;
203
+ }
204
+ }
205
+ /**
206
+ * True iff `dir` is an HQ root for the cwd walk-up: it has a `core.yaml`
207
+ * (canonical `core/core.yaml` since hq-core v14, or legacy `<dir>/core.yaml`)
208
+ * AND a sibling `companies/` directory. The `companies/` discriminator keeps
209
+ * the walk from latching onto the inner `core/` subtree, which carries its own
210
+ * `core.yaml` but no `companies/`.
211
+ */
105
212
  function isHqRoot(dir) {
106
- if (!fs.existsSync(path.join(dir, "core.yaml")))
213
+ const hasCoreYaml = fileExists(path.join(dir, "core", "core.yaml")) ||
214
+ fileExists(path.join(dir, "core.yaml"));
215
+ if (!hasCoreYaml)
107
216
  return false;
108
217
  try {
109
218
  return fs.statSync(path.join(dir, "companies")).isDirectory();
@@ -193,4 +302,4 @@ export async function refreshCachedSession() {
193
302
  }
194
303
  }
195
304
  //# sourceMappingURL=cognito-session.js.map
196
- //# debugId=cd9690e0-c7b0-5e26-a3ee-7f7bc98a0fe2
305
+ //# debugId=984bafe3-242a-5322-a2fe-65261b24ee0b
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.36.4",
3
+ "version": "5.36.6",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^6.2.4",
18
+ "@indigoai-us/hq-cloud": "^6.2.6",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -19,12 +19,23 @@ describe("resolveDefaultHqRoot", () => {
19
19
  let tmpRoot: string;
20
20
  let origCwd: string;
21
21
  let origEnv: string | undefined;
22
+ let origHome: string | undefined;
23
+
24
+ // `~/.hq` records (menubar.json/config.json) + discovery candidates are all
25
+ // keyed off os.homedir(), which honors $HOME on POSIX/macOS. Pin HOME to a
26
+ // clean tmp dir so every tier is hermetic — the real machine's HQ install
27
+ // can't leak into the fallback/discovery assertions below.
28
+ let fakeHome: string;
22
29
 
23
30
  beforeEach(() => {
24
31
  tmpRoot = mkdtempSync(join(tmpdir(), "hq-cli-resolveroot-"));
32
+ fakeHome = join(tmpRoot, "home");
33
+ mkdirSync(fakeHome, { recursive: true });
25
34
  origCwd = process.cwd();
26
35
  origEnv = process.env.HQ_ROOT;
36
+ origHome = process.env.HOME;
27
37
  delete process.env.HQ_ROOT;
38
+ process.env.HOME = fakeHome;
28
39
  });
29
40
 
30
41
  afterEach(() => {
@@ -32,8 +43,17 @@ describe("resolveDefaultHqRoot", () => {
32
43
  rmSync(tmpRoot, { recursive: true, force: true });
33
44
  if (origEnv === undefined) delete process.env.HQ_ROOT;
34
45
  else process.env.HQ_ROOT = origEnv;
46
+ if (origHome === undefined) delete process.env.HOME;
47
+ else process.env.HOME = origHome;
35
48
  });
36
49
 
50
+ /** Write `~/.hq/<file>` (creating `~/.hq`) with the given JSON object. */
51
+ function writeHqConfig(file: string, obj: Record<string, unknown>): void {
52
+ const hqDir = join(fakeHome, ".hq");
53
+ mkdirSync(hqDir, { recursive: true });
54
+ writeFileSync(join(hqDir, file), JSON.stringify(obj));
55
+ }
56
+
37
57
  it("priority 1: honors $HQ_ROOT env var (resolved to absolute path)", () => {
38
58
  const explicit = join(tmpRoot, "explicit-target");
39
59
  mkdirSync(explicit, { recursive: true });
@@ -75,22 +95,21 @@ describe("resolveDefaultHqRoot", () => {
75
95
  expect(resolveDefaultHqRoot()).toBe(realpathSync(hqDir));
76
96
  });
77
97
 
78
- it("priority 3: falls back when neither $HQ_ROOT nor an HQ-root marker pair are found", () => {
98
+ it("tier 6: falls back to ~/hq when nothing (env, cwd, ~/.hq records, discovery) resolves", () => {
79
99
  const stranded = join(tmpRoot, "stranded");
80
100
  mkdirSync(stranded, { recursive: true });
81
101
  process.chdir(stranded);
82
102
 
83
103
  const result = resolveDefaultHqRoot();
84
- // Walks up to filesystem root, no core.yaml+companies/ pair found, falls
85
- // back to ~/hq. We don't assert the exact value (depends on the test
86
- // runner's $HOME) but the result should NOT be the stranded dir.
104
+ // No $HQ_ROOT, no core.yaml+companies/ above cwd, no ~/.hq records, no
105
+ // discoverable root under the (pinned) home last-resort ~/hq.
87
106
  expect(result).not.toBe(stranded);
88
- expect(result.endsWith("/hq")).toBe(true);
107
+ expect(result).toBe(join(fakeHome, "hq"));
89
108
  });
90
109
 
91
- it("priority 3: a dir with only core.yaml (no companies/) is NOT a valid HQ root", () => {
92
- // core.yaml present but no companies/ sibling → fall through to ~/hq.
93
- // This is the synced core/ subtree's signature.
110
+ it("a dir with only core.yaml (no companies/) is NOT a valid HQ root (walk-up)", () => {
111
+ // core.yaml present but no companies/ sibling → not a root; with no other
112
+ // signal, fall through to ~/hq. This is the synced core/ subtree's shape.
94
113
  const lonelyCoreYaml = join(tmpRoot, "lonely");
95
114
  mkdirSync(lonelyCoreYaml, { recursive: true });
96
115
  writeFileSync(join(lonelyCoreYaml, "core.yaml"), "");
@@ -98,7 +117,7 @@ describe("resolveDefaultHqRoot", () => {
98
117
 
99
118
  const result = resolveDefaultHqRoot();
100
119
  expect(result).not.toBe(lonelyCoreYaml);
101
- expect(result.endsWith("/hq")).toBe(true);
120
+ expect(result).toBe(join(fakeHome, "hq"));
102
121
  });
103
122
 
104
123
  it("$HQ_ROOT wins over walking-up resolution", () => {
@@ -115,6 +134,125 @@ describe("resolveDefaultHqRoot", () => {
115
134
  expect(resolveDefaultHqRoot()).toBe(explicit);
116
135
  });
117
136
 
137
+ // ── tiers 3–5: installer/menubar records + discovery (the feedback_3967e294
138
+ // split-brain fix) ────────────────────────────────────────────────────
139
+ //
140
+ // REGRESSION: run from a cwd OUTSIDE any HQ tree with $HQ_ROOT unset. Before
141
+ // the fix the CLI fell straight to ~/hq and the sync engine created+filled a
142
+ // brand-new stub there, diverging from the AppBar's real root. It must now
143
+ // read ~/.hq/menubar.json → hqPath (the same record HQ Sync reads first).
144
+ it("tier 3: reads ~/.hq/menubar.json → hqPath from an unrelated cwd (feedback_3967e294)", () => {
145
+ const realRoot = join(tmpRoot, "Desktop", "HQ");
146
+ mkdirSync(realRoot, { recursive: true });
147
+ writeHqConfig("menubar.json", { machineId: "abc", hqPath: realRoot });
148
+
149
+ const stranded = join(tmpRoot, "some", "unrelated", "cwd");
150
+ mkdirSync(stranded, { recursive: true });
151
+ process.chdir(stranded);
152
+
153
+ expect(resolveDefaultHqRoot()).toBe(realRoot);
154
+ });
155
+
156
+ it("tier 4: reads ~/.hq/config.json → hqFolderPath when menubar.json is absent", () => {
157
+ const realRoot = join(tmpRoot, "Desktop", "HQ");
158
+ mkdirSync(realRoot, { recursive: true });
159
+ writeHqConfig("config.json", { hqFolderPath: realRoot });
160
+
161
+ const stranded = join(tmpRoot, "unrelated");
162
+ mkdirSync(stranded, { recursive: true });
163
+ process.chdir(stranded);
164
+
165
+ expect(resolveDefaultHqRoot()).toBe(realRoot);
166
+ });
167
+
168
+ it("tier 3 wins over tier 4: menubar.json hqPath beats config.json hqFolderPath", () => {
169
+ const fromMenubar = join(tmpRoot, "menubar-root");
170
+ const fromConfig = join(tmpRoot, "config-root");
171
+ mkdirSync(fromMenubar, { recursive: true });
172
+ mkdirSync(fromConfig, { recursive: true });
173
+ writeHqConfig("menubar.json", { hqPath: fromMenubar });
174
+ writeHqConfig("config.json", { hqFolderPath: fromConfig });
175
+
176
+ process.chdir(tmpRoot);
177
+ expect(resolveDefaultHqRoot()).toBe(fromMenubar);
178
+ });
179
+
180
+ it("an empty hqPath in menubar.json falls through to config.json (mirrors hq-sync)", () => {
181
+ const fromConfig = join(tmpRoot, "config-root");
182
+ mkdirSync(fromConfig, { recursive: true });
183
+ writeHqConfig("menubar.json", { hqPath: "" });
184
+ writeHqConfig("config.json", { hqFolderPath: fromConfig });
185
+
186
+ process.chdir(tmpRoot);
187
+ expect(resolveDefaultHqRoot()).toBe(fromConfig);
188
+ });
189
+
190
+ it("tier 2 (cwd walk-up) wins over tier 3 (menubar.json) — operate on the tree you're inside", () => {
191
+ // A user cd'd into a real HQ checkout must target THAT tree even when
192
+ // menubar.json points elsewhere — least-surprising for multi-checkout devs.
193
+ const insideRoot = join(tmpRoot, "checkout-a");
194
+ const nested = join(insideRoot, "companies", "acme");
195
+ mkdirSync(nested, { recursive: true });
196
+ mkdirSync(join(insideRoot, "core"), { recursive: true });
197
+ writeFileSync(
198
+ join(insideRoot, "core", "core.yaml"),
199
+ "version: 1\nhqVersion: \"15.0.0\"\n",
200
+ );
201
+ writeHqConfig("menubar.json", { hqPath: join(tmpRoot, "checkout-b") });
202
+
203
+ process.chdir(nested);
204
+ expect(resolveDefaultHqRoot()).toBe(realpathSync(insideRoot));
205
+ });
206
+
207
+ it("tier 2: walk-up detects a v14+/v15 root whose core.yaml lives at core/core.yaml", () => {
208
+ // hq-core v14 moved core.yaml from <root>/core.yaml to <root>/core/core.yaml.
209
+ // The walk-up must detect the modern layout (this was the second half of
210
+ // the bug — the CLI only checked the legacy root-level core.yaml).
211
+ const hqDir = join(tmpRoot, "Desktop", "HQ");
212
+ const nested = join(hqDir, "companies", "acme");
213
+ mkdirSync(nested, { recursive: true });
214
+ mkdirSync(join(hqDir, "core"), { recursive: true });
215
+ writeFileSync(
216
+ join(hqDir, "core", "core.yaml"),
217
+ "version: 1\nhqVersion: \"15.0.11\"\n",
218
+ );
219
+
220
+ process.chdir(nested);
221
+ expect(resolveDefaultHqRoot()).toBe(realpathSync(hqDir));
222
+ });
223
+
224
+ it("tier 5: discovers ~/HQ via a valid core/core.yaml signature when no ~/.hq records exist", () => {
225
+ // No $HQ_ROOT, no walk-up hit, no menubar/config records → discovery scans
226
+ // the well-known locations. ~/HQ with a schema-valid core/core.yaml wins.
227
+ const discovered = join(fakeHome, "HQ");
228
+ mkdirSync(join(discovered, "core"), { recursive: true });
229
+ writeFileSync(
230
+ join(discovered, "core", "core.yaml"),
231
+ "version: 1\nhqVersion: \"15.0.11\"\n",
232
+ );
233
+
234
+ const stranded = join(tmpRoot, "unrelated");
235
+ mkdirSync(stranded, { recursive: true });
236
+ process.chdir(stranded);
237
+
238
+ expect(resolveDefaultHqRoot()).toBe(discovered);
239
+ });
240
+
241
+ it("tier 5: discovery REJECTS a core.yaml lacking the version+hqVersion schema", () => {
242
+ // A stray core.yaml (from another tool) must not false-match — both fields
243
+ // are required, mirroring hq-sync's is_valid_hq_root.
244
+ const notHq = join(fakeHome, "HQ");
245
+ mkdirSync(join(notHq, "core"), { recursive: true });
246
+ writeFileSync(join(notHq, "core", "core.yaml"), "someOtherTool: true\n");
247
+
248
+ const stranded = join(tmpRoot, "unrelated");
249
+ mkdirSync(stranded, { recursive: true });
250
+ process.chdir(stranded);
251
+
252
+ // No valid discovery hit → tier 6 fallback.
253
+ expect(resolveDefaultHqRoot()).toBe(join(fakeHome, "hq"));
254
+ });
255
+
118
256
  // ── onMissing: throw vs fallback ──────────────────────────────────────
119
257
  //
120
258
  // Default behavior (no opts) is fallback to ~/hq — preserves the
@@ -22,6 +22,7 @@
22
22
  import * as fs from "fs";
23
23
  import * as os from "os";
24
24
  import * as path from "path";
25
+ import * as yaml from "js-yaml";
25
26
  import chalk from "chalk";
26
27
  import {
27
28
  loadCachedTokens,
@@ -57,69 +58,193 @@ export const DEFAULT_VAULT_API_URL =
57
58
  process.env.HQ_VAULT_API_URL ?? "https://hqapi.getindigo.ai";
58
59
 
59
60
  /**
60
- * Resolve the default HQ tree root for cloud-aware subcommands.
61
+ * Resolve the HQ tree root for cloud-aware subcommands (`hq sync`, `hq onboard`,
62
+ * `hq cloud …`, etc.).
61
63
  *
62
- * Priority order:
63
- * 1. `$HQ_ROOT` env var (explicit user override)
64
- * 2. Walk up from `process.cwd()` to the nearest dir containing BOTH a
65
- * `core.yaml` file AND a `companies/` directory (root-unique marker
66
- * pair see note below).
67
- * 3. Fall back to `~/hq` (the historical default)
64
+ * Resolution order — mirrors the menubar/installer resolver
65
+ * (`hq-sync` `util/paths.rs::resolve_hq_folder`) so the CLI targets the SAME
66
+ * root the AppBar syncs, regardless of cwd:
67
+ * 1. `$HQ_ROOT` env var explicit assertion by the caller; short-circuits all.
68
+ * 2. Walk up from `process.cwd()` to the nearest HQ root (working-tree wins:
69
+ * if you're inside an HQ tree, operate on THAT tree). See {@link isHqRoot}.
70
+ * 3. `~/.hq/menubar.json` → `hqPath` — the canonical record written by the
71
+ * installer (≥0.1.28) and the menubar's Settings re-tether. Same field HQ
72
+ * Sync reads first.
73
+ * 4. `~/.hq/config.json` → `hqFolderPath` — legacy installer record.
74
+ * 5. Discovery: scan well-known locations (`~/HQ`, `~/hq`, `~/Documents/HQ`,
75
+ * …) for a folder carrying a valid `core.yaml` signature. Safety net for
76
+ * installs that never wrote the path back to menubar.json.
77
+ * 6. Fall back to `~/hq` (the historical CLI default) — or throw, per
78
+ * `opts.onMissing`.
68
79
  *
69
- * Why both markers?
70
- * The HQ root has `core.yaml` AND a sibling `companies/` directory. The
71
- * synced `core/` subtree (which is itself part of the root's personal-vault
72
- * scope) ALSO contains a `core.yaml` (the template's version-source-of-
73
- * truth), but does NOT contain `companies/`. Single-marker `core.yaml`
74
- * detection would stop at `<hqRoot>/core/` when the CLI is launched from
75
- * somewhere inside that subtree, and downstream `companies/` lookups would
76
- * silently miss the real content. Requiring `companies/` as well guarantees
77
- * we resolve to the actual HQ root (Codex P2 on hq#146).
80
+ * Why tiers 3–5 exist:
81
+ * Before this, the CLI only knew `$HQ_ROOT` and the cwd walk-up. Run from any
82
+ * cwd OUTSIDE the HQ tree with `$HQ_ROOT` unset, it fell straight to `~/hq` —
83
+ * so a user whose HQ lives anywhere else (e.g. `~/Desktop/HQ`, as the AppBar
84
+ * syncs it) silently got a brand-new `~/hq` stub and a full re-sync into it
85
+ * (split-brain). Reading the installer/menubar records the way HQ Sync does
86
+ * closes that gap (feedback_3967e294).
78
87
  *
79
- * Evaluated once at module load commander.js `.option()` callers pin the
80
- * value at registration time, which matches the user's actual cwd at process
81
- * start. Re-importable as a function for tests and command-time resolution.
82
- */
83
- /**
84
- * Resolve the HQ root directory.
85
- *
86
- * Resolution order:
87
- * 1. $HQ_ROOT env var (treated as an explicit assertion by the caller)
88
- * 2. Walk up from cwd looking for `core.yaml` AND `companies/` siblings
89
- * 3. Fall back to `~/hq` (or throw, per `opts.onMissing`)
88
+ * Why both markers in the walk-up (tier 2)?
89
+ * An HQ root has a `core.yaml` (canonical `core/core.yaml` since hq-core v14,
90
+ * legacy `<root>/core.yaml` before) AND a sibling `companies/` directory. The
91
+ * synced `core/` subtree ALSO contains a `core.yaml` (the template version
92
+ * source-of-truth) but NO `companies/`. Requiring `companies/` as well stops
93
+ * the walk from latching onto `<hqRoot>/core/` (Codex P2 on hq#146).
90
94
  *
91
- * `opts.onMissing` controls the third arm:
92
- * - `'fallback'` (default) — return `~/hq` if no HQ root is found above cwd.
93
- * This preserves the module-load contract of `DEFAULT_HQ_ROOT`, which
94
- * several commander.js `.option()` callers pin at registration time.
95
- * - `'throw'` — throw with a user-actionable error. Used by module-management
96
- * commands (pkg-install, pkg-remove, pkg-list, pkg-update, team-sync) where
97
- * a silent default-path miss would silently target the wrong directory.
95
+ * `opts.onMissing` controls the final arm:
96
+ * - `'fallback'` (default) — return `~/hq` if nothing resolves. Preserves the
97
+ * module-load contract of `DEFAULT_HQ_ROOT`, pinned by commander.js
98
+ * `.option()` callers at registration time.
99
+ * - `'throw'` — throw a user-actionable error. Used by module-management
100
+ * commands (pkg-*, team-sync) where a silent default-path miss would
101
+ * target the wrong directory.
98
102
  *
99
- * `$HQ_ROOT` short-circuits both arms if the env var is set, it's used
100
- * as-is regardless of `onMissing`.
103
+ * Evaluated once at module load for `DEFAULT_HQ_ROOT`; the installer/menubar
104
+ * records and cwd are both fixed at process start, so a one-shot read is sound.
105
+ * Re-importable as a function for command-time resolution and tests.
101
106
  */
102
107
  export function resolveDefaultHqRoot(opts: {
103
108
  onMissing?: "throw" | "fallback";
104
109
  } = {}): string {
110
+ // Tier 1: explicit override.
105
111
  if (process.env.HQ_ROOT) return path.resolve(process.env.HQ_ROOT);
112
+
113
+ // Tier 2: working-tree walk-up — operate on the HQ tree you're inside.
106
114
  let cur = path.resolve(process.cwd());
107
115
  while (cur !== path.dirname(cur)) {
108
116
  if (isHqRoot(cur)) return cur;
109
117
  cur = path.dirname(cur);
110
118
  }
119
+
120
+ // Tiers 3–4: the installer/menubar's canonical "where is HQ" records.
121
+ const fromConfig = readHqRootFromHqConfig(path.join(os.homedir(), ".hq"));
122
+ if (fromConfig) return path.resolve(fromConfig);
123
+
124
+ // Tier 5: discover a valid HQ root in the well-known locations.
125
+ const discovered = discoverHqRootViaCoreYaml();
126
+ if (discovered) return discovered;
127
+
128
+ // Tier 6: nothing resolved.
111
129
  if (opts.onMissing === "throw") {
112
130
  throw new Error(
113
131
  "Could not find HQ root. Run this command from within your HQ directory " +
114
- "(must contain core.yaml AND a companies/ subdirectory), or set $HQ_ROOT.",
132
+ "(must contain core.yaml AND a companies/ subdirectory), set $HQ_ROOT, " +
133
+ "or install HQ via the installer/menubar so ~/.hq/menubar.json records " +
134
+ "its location.",
115
135
  );
116
136
  }
117
137
  return path.join(os.homedir(), "hq");
118
138
  }
119
139
 
120
- /** True iff `dir` looks like an HQ root (has core.yaml + companies/ dir). */
140
+ /**
141
+ * Read the configured HQ root from the installer/menubar records under
142
+ * `hqConfigDir` (`~/.hq`). Mirrors HQ Sync's precedence:
143
+ * 1. `menubar.json` → `hqPath` (canonical; written by installer ≥0.1.28 and
144
+ * the menubar Settings re-tether)
145
+ * 2. `config.json` → `hqFolderPath` (legacy installer flows)
146
+ * Returns the first non-empty string, or undefined. Best-effort: a missing or
147
+ * malformed file is treated as "not configured" (never throws).
148
+ */
149
+ function readHqRootFromHqConfig(hqConfigDir: string): string | undefined {
150
+ const fromMenubar = readJsonStringField(
151
+ path.join(hqConfigDir, "menubar.json"),
152
+ "hqPath",
153
+ );
154
+ if (fromMenubar) return fromMenubar;
155
+ return readJsonStringField(
156
+ path.join(hqConfigDir, "config.json"),
157
+ "hqFolderPath",
158
+ );
159
+ }
160
+
161
+ /** Read a top-level string field from a JSON file, or undefined on any miss. */
162
+ function readJsonStringField(file: string, field: string): string | undefined {
163
+ try {
164
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as Record<
165
+ string,
166
+ unknown
167
+ >;
168
+ const value = parsed[field];
169
+ return typeof value === "string" && value.length > 0 ? value : undefined;
170
+ } catch {
171
+ return undefined;
172
+ }
173
+ }
174
+
175
+ /** Well-known parent locations an HQ tree commonly lives in (mirrors hq-sync). */
176
+ function hqDiscoveryCandidates(): string[] {
177
+ const home = os.homedir();
178
+ return [
179
+ path.join(home, "HQ"),
180
+ path.join(home, "hq"),
181
+ path.join(home, "Documents", "HQ"),
182
+ path.join(home, "Documents", "hq"),
183
+ path.join(home, "Desktop", "HQ"),
184
+ path.join(home, "Desktop", "hq"),
185
+ ];
186
+ }
187
+
188
+ /**
189
+ * Scan the well-known locations for a folder carrying a valid `core.yaml`
190
+ * signature. First match wins; returns undefined if none qualify. Cheap — a
191
+ * few stats plus one small YAML parse on a hit.
192
+ */
193
+ function discoverHqRootViaCoreYaml(): string | undefined {
194
+ return hqDiscoveryCandidates().find(isValidHqRootSignature);
195
+ }
196
+
197
+ /**
198
+ * True iff `dir` carries a valid hq-core `core.yaml` — canonical
199
+ * (`<dir>/core/core.yaml`, hq-core ≥v14) or legacy (`<dir>/core.yaml`) — that
200
+ * parses as YAML and has BOTH the `version` and `hqVersion` fields. The dual
201
+ * field check (matching hq-sync's `is_valid_hq_root`) stops a stray `core.yaml`
202
+ * from another tool from false-matching.
203
+ */
204
+ function isValidHqRootSignature(dir: string): boolean {
205
+ const canonical = path.join(dir, "core", "core.yaml");
206
+ const legacy = path.join(dir, "core.yaml");
207
+ let file: string;
208
+ if (fileExists(canonical)) file = canonical;
209
+ else if (fileExists(legacy)) file = legacy;
210
+ else return false;
211
+ try {
212
+ const parsed = yaml.load(fs.readFileSync(file, "utf-8")) as
213
+ | Record<string, unknown>
214
+ | null
215
+ | undefined;
216
+ return (
217
+ !!parsed &&
218
+ typeof parsed === "object" &&
219
+ "version" in parsed &&
220
+ "hqVersion" in parsed
221
+ );
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ /** True iff `p` exists and is a regular file. */
228
+ function fileExists(p: string): boolean {
229
+ try {
230
+ return fs.statSync(p).isFile();
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * True iff `dir` is an HQ root for the cwd walk-up: it has a `core.yaml`
238
+ * (canonical `core/core.yaml` since hq-core v14, or legacy `<dir>/core.yaml`)
239
+ * AND a sibling `companies/` directory. The `companies/` discriminator keeps
240
+ * the walk from latching onto the inner `core/` subtree, which carries its own
241
+ * `core.yaml` but no `companies/`.
242
+ */
121
243
  function isHqRoot(dir: string): boolean {
122
- if (!fs.existsSync(path.join(dir, "core.yaml"))) return false;
244
+ const hasCoreYaml =
245
+ fileExists(path.join(dir, "core", "core.yaml")) ||
246
+ fileExists(path.join(dir, "core.yaml"));
247
+ if (!hasCoreYaml) return false;
123
248
  try {
124
249
  return fs.statSync(path.join(dir, "companies")).isDirectory();
125
250
  } catch {