@indigoai-us/hq-cli 5.36.3 → 5.36.5
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/dist/commands/onboard-identity-guard.d.ts +53 -0
- package/dist/commands/onboard-identity-guard.js +94 -0
- package/dist/commands/onboard.js +36 -4
- package/dist/utils/cognito-session.d.ts +40 -36
- package/dist/utils/cognito-session.js +150 -41
- package/package.json +1 -1
- package/src/commands/onboard-identity-guard.test.ts +99 -0
- package/src/commands/onboard-identity-guard.ts +103 -0
- package/src/commands/onboard.ts +44 -1
- package/src/utils/cognito-session.test.ts +147 -9
- package/src/utils/cognito-session.ts +164 -39
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding identity-link guard (DEV-1695 / DEV-1701 / DEV-1721).
|
|
3
|
+
*
|
|
4
|
+
* Failure mode this guards against:
|
|
5
|
+
*
|
|
6
|
+
* The onboarding orchestrator's `create-person` step resolves the caller's
|
|
7
|
+
* person entity by an email-derived slug GLOBALLY (not scoped to the caller's
|
|
8
|
+
* Cognito identity). When a user signs back in under a DIFFERENT Cognito
|
|
9
|
+
* `sub` — e.g. a new Google account, an email change, or a linked-IdP sub
|
|
10
|
+
* swap — the slug lookup still finds the person row created under the ORIGINAL
|
|
11
|
+
* sub and records it in the checkpoint's `personUid`. `create-person` is then
|
|
12
|
+
* marked complete and is never re-validated against the live identity.
|
|
13
|
+
*
|
|
14
|
+
* On every `hq onboard resume`, `create-person` is skipped as "already
|
|
15
|
+
* complete" while the stale `personUid` is carried forward. The server
|
|
16
|
+
* correctly refuses to bootstrap a company membership for a person the caller
|
|
17
|
+
* does not own (it resolves the caller's person by the live Cognito sub), so
|
|
18
|
+
* resume re-fails at `bootstrap-membership` every single time — an infinite
|
|
19
|
+
* loop with a misleading downstream error.
|
|
20
|
+
*
|
|
21
|
+
* This module is the detection half of the fix: given the local checkpoint and
|
|
22
|
+
* the set of person entities ACTUALLY owned by the current caller (the server
|
|
23
|
+
* scopes `/entity/by-type/person` by the live Cognito sub), it reports whether
|
|
24
|
+
* the checkpoint adopted a person the caller no longer owns. The resume command
|
|
25
|
+
* uses it to surface a recoverable, actionable error instead of looping.
|
|
26
|
+
*/
|
|
27
|
+
import type { OnboardingCheckpoint } from "@indigoai-us/hq-onboarding";
|
|
28
|
+
export type OnboardingIdentityCheck = {
|
|
29
|
+
kind: "ok";
|
|
30
|
+
} | {
|
|
31
|
+
kind: "mismatch";
|
|
32
|
+
personUid: string;
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Detect the person-entity-vs-Cognito-sub mismatch that wedges
|
|
37
|
+
* `hq onboard resume` into an infinite loop.
|
|
38
|
+
*
|
|
39
|
+
* Returns `{ kind: "ok" }` whenever the flow should proceed normally:
|
|
40
|
+
* - there is no checkpoint, or
|
|
41
|
+
* - the checkpoint never recorded an adopted person (`personUid` unset, or
|
|
42
|
+
* `create-person` not yet completed), or
|
|
43
|
+
* - the adopted person is among those owned by the current caller.
|
|
44
|
+
*
|
|
45
|
+
* Returns `{ kind: "mismatch", ... }` only when the checkpoint completed
|
|
46
|
+
* `create-person` with a `personUid` that the current caller does NOT own —
|
|
47
|
+
* the exact state that loops forever at `bootstrap-membership`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function detectOnboardingIdentityMismatch(input: {
|
|
50
|
+
checkpoint: OnboardingCheckpoint | null;
|
|
51
|
+
ownedPersonUids: readonly string[];
|
|
52
|
+
}): OnboardingIdentityCheck;
|
|
53
|
+
//# sourceMappingURL=onboard-identity-guard.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding identity-link guard (DEV-1695 / DEV-1701 / DEV-1721).
|
|
3
|
+
*
|
|
4
|
+
* Failure mode this guards against:
|
|
5
|
+
*
|
|
6
|
+
* The onboarding orchestrator's `create-person` step resolves the caller's
|
|
7
|
+
* person entity by an email-derived slug GLOBALLY (not scoped to the caller's
|
|
8
|
+
* Cognito identity). When a user signs back in under a DIFFERENT Cognito
|
|
9
|
+
* `sub` — e.g. a new Google account, an email change, or a linked-IdP sub
|
|
10
|
+
* swap — the slug lookup still finds the person row created under the ORIGINAL
|
|
11
|
+
* sub and records it in the checkpoint's `personUid`. `create-person` is then
|
|
12
|
+
* marked complete and is never re-validated against the live identity.
|
|
13
|
+
*
|
|
14
|
+
* On every `hq onboard resume`, `create-person` is skipped as "already
|
|
15
|
+
* complete" while the stale `personUid` is carried forward. The server
|
|
16
|
+
* correctly refuses to bootstrap a company membership for a person the caller
|
|
17
|
+
* does not own (it resolves the caller's person by the live Cognito sub), so
|
|
18
|
+
* resume re-fails at `bootstrap-membership` every single time — an infinite
|
|
19
|
+
* loop with a misleading downstream error.
|
|
20
|
+
*
|
|
21
|
+
* This module is the detection half of the fix: given the local checkpoint and
|
|
22
|
+
* the set of person entities ACTUALLY owned by the current caller (the server
|
|
23
|
+
* scopes `/entity/by-type/person` by the live Cognito sub), it reports whether
|
|
24
|
+
* the checkpoint adopted a person the caller no longer owns. The resume command
|
|
25
|
+
* uses it to surface a recoverable, actionable error instead of looping.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Build the human-facing recovery message for a detected mismatch.
|
|
29
|
+
*
|
|
30
|
+
* Only the two recovery paths that ACTUALLY work are offered. Deleting the
|
|
31
|
+
* checkpoint and re-running `create-company` does NOT help: the email-derived
|
|
32
|
+
* slug would re-adopt the same other-owned person row, so it is deliberately
|
|
33
|
+
* not suggested.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
!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]="5e986156-276e-5583-a43d-74696c122d40")}catch(e){}}();
|
|
37
|
+
function buildMismatchMessage(personUid) {
|
|
38
|
+
return [
|
|
39
|
+
`Onboarding can't continue — an identity-link mismatch is blocking resume.`,
|
|
40
|
+
``,
|
|
41
|
+
`Your saved onboarding checkpoint is linked to person record ${personUid},`,
|
|
42
|
+
`but that record is owned by a different sign-in than the one you're using`,
|
|
43
|
+
`now. This usually means onboarding was started under one identity (one`,
|
|
44
|
+
`Google account / email) and later resumed under a different one.`,
|
|
45
|
+
``,
|
|
46
|
+
`Because the person record belongs to the original sign-in, resume can't`,
|
|
47
|
+
`bootstrap your company membership and would otherwise retry forever.`,
|
|
48
|
+
``,
|
|
49
|
+
`To recover, do ONE of the following:`,
|
|
50
|
+
` 1. Sign out and sign back in with your ORIGINAL onboarding identity,`,
|
|
51
|
+
` then re-run 'hq onboard resume'.`,
|
|
52
|
+
` 2. Ask an HQ admin to relink person ${personUid} to your current`,
|
|
53
|
+
` sign-in (reference Linear DEV-1695), then re-run 'hq onboard resume'.`,
|
|
54
|
+
``,
|
|
55
|
+
`Nothing was changed — your data is safe.`,
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Detect the person-entity-vs-Cognito-sub mismatch that wedges
|
|
60
|
+
* `hq onboard resume` into an infinite loop.
|
|
61
|
+
*
|
|
62
|
+
* Returns `{ kind: "ok" }` whenever the flow should proceed normally:
|
|
63
|
+
* - there is no checkpoint, or
|
|
64
|
+
* - the checkpoint never recorded an adopted person (`personUid` unset, or
|
|
65
|
+
* `create-person` not yet completed), or
|
|
66
|
+
* - the adopted person is among those owned by the current caller.
|
|
67
|
+
*
|
|
68
|
+
* Returns `{ kind: "mismatch", ... }` only when the checkpoint completed
|
|
69
|
+
* `create-person` with a `personUid` that the current caller does NOT own —
|
|
70
|
+
* the exact state that loops forever at `bootstrap-membership`.
|
|
71
|
+
*/
|
|
72
|
+
export function detectOnboardingIdentityMismatch(input) {
|
|
73
|
+
const { checkpoint, ownedPersonUids } = input;
|
|
74
|
+
if (!checkpoint)
|
|
75
|
+
return { kind: "ok" };
|
|
76
|
+
const adopted = checkpoint.personUid;
|
|
77
|
+
if (!adopted)
|
|
78
|
+
return { kind: "ok" };
|
|
79
|
+
// Mirror the orchestrator's `isStepComplete`: a personUid that predates the
|
|
80
|
+
// create-person step completing has not been committed as the adopted
|
|
81
|
+
// identity yet, so don't treat it as a mismatch.
|
|
82
|
+
if (!checkpoint.completedSteps?.includes("create-person")) {
|
|
83
|
+
return { kind: "ok" };
|
|
84
|
+
}
|
|
85
|
+
if (ownedPersonUids.includes(adopted))
|
|
86
|
+
return { kind: "ok" };
|
|
87
|
+
return {
|
|
88
|
+
kind: "mismatch",
|
|
89
|
+
personUid: adopted,
|
|
90
|
+
message: buildMismatchMessage(adopted),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=onboard-identity-guard.js.map
|
|
94
|
+
//# debugId=5e986156-276e-5583-a43d-74696c122d40
|
package/dist/commands/onboard.js
CHANGED
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
* browser-OAuth flow opens automatically.
|
|
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]="8f6c7f48-0ce5-55f3-bfd9-b4a6040adb0b")}catch(e){}}();
|
|
23
23
|
import chalk from "chalk";
|
|
24
|
-
import { runOnboardCli } from "@indigoai-us/hq-onboarding";
|
|
25
|
-
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
24
|
+
import { runOnboardCli, readCheckpoint } from "@indigoai-us/hq-onboarding";
|
|
25
|
+
import { DEFAULT_HQ_ROOT, DEFAULT_VAULT_API_URL, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
26
|
+
import { createDefaultVaultClient } from "./cloud-provision.js";
|
|
27
|
+
import { detectOnboardingIdentityMismatch } from "./onboard-identity-guard.js";
|
|
26
28
|
// ---------------------------------------------------------------------------
|
|
27
29
|
// Command registration
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
@@ -102,6 +104,36 @@ export function registerOnboardCommand(program) {
|
|
|
102
104
|
.action(async (options) => {
|
|
103
105
|
try {
|
|
104
106
|
const accessToken = await ensureCognitoToken();
|
|
107
|
+
// Identity-link pre-flight (DEV-1695 / DEV-1701 / DEV-1721): if the
|
|
108
|
+
// saved checkpoint adopted a person entity owned by a DIFFERENT Cognito
|
|
109
|
+
// sign-in than the current one, resume would skip the completed
|
|
110
|
+
// create-person step with that stale personUid and re-fail at
|
|
111
|
+
// bootstrap-membership forever. Detect it up front and surface a
|
|
112
|
+
// recoverable, actionable error instead of looping.
|
|
113
|
+
const checkpoint = await readCheckpoint(options.hqRoot);
|
|
114
|
+
if (checkpoint?.personUid) {
|
|
115
|
+
let ownedPersonUids = null;
|
|
116
|
+
try {
|
|
117
|
+
const client = createDefaultVaultClient(DEFAULT_VAULT_API_URL, accessToken);
|
|
118
|
+
const owned = await client.listMyPersonEntities();
|
|
119
|
+
ownedPersonUids = owned.map((p) => p.uid);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
// Best-effort guard: if the pre-flight lookup itself fails (network,
|
|
123
|
+
// auth), don't block resume — but don't swallow it silently either.
|
|
124
|
+
console.warn(chalk.yellow(` (skipping identity pre-flight check: ${err instanceof Error ? err.message : String(err)})`));
|
|
125
|
+
}
|
|
126
|
+
if (ownedPersonUids) {
|
|
127
|
+
const check = detectOnboardingIdentityMismatch({
|
|
128
|
+
checkpoint,
|
|
129
|
+
ownedPersonUids,
|
|
130
|
+
});
|
|
131
|
+
if (check.kind === "mismatch") {
|
|
132
|
+
console.error(chalk.red(`\n✗ Resume blocked:\n\n${check.message}`));
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
105
137
|
const result = await runOnboardCli({
|
|
106
138
|
mode: "resume",
|
|
107
139
|
vaultConfig: buildVaultConfig(accessToken),
|
|
@@ -142,4 +174,4 @@ export function registerOnboardCommand(program) {
|
|
|
142
174
|
});
|
|
143
175
|
}
|
|
144
176
|
//# sourceMappingURL=onboard.js.map
|
|
145
|
-
//# debugId=
|
|
177
|
+
//# debugId=8f6c7f48-0ce5-55f3-bfd9-b4a6040adb0b
|
|
@@ -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
|
|
25
|
+
* Resolve the HQ tree root for cloud-aware subcommands (`hq sync`, `hq onboard`,
|
|
26
|
+
* `hq cloud …`, etc.).
|
|
26
27
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
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
|
|
57
|
-
* - `'fallback'` (default) — return `~/hq` if
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* - `'throw'` — throw
|
|
61
|
-
* commands (pkg
|
|
62
|
-
*
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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]="
|
|
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
|
|
48
|
+
* Resolve the HQ tree root for cloud-aware subcommands (`hq sync`, `hq onboard`,
|
|
49
|
+
* `hq cloud …`, etc.).
|
|
48
50
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
* `
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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),
|
|
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
|
-
/**
|
|
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
|
-
|
|
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=
|
|
305
|
+
//# debugId=984bafe3-242a-5322-a2fe-65261b24ee0b
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the onboarding identity-link guard
|
|
3
|
+
* (DEV-1695 / DEV-1701 / DEV-1721).
|
|
4
|
+
*
|
|
5
|
+
* Covers the resume-with-mismatched-sub path: a checkpoint that completed
|
|
6
|
+
* `create-person` against a `personUid` the current caller does NOT own must
|
|
7
|
+
* be detected as a recoverable mismatch instead of being allowed to loop
|
|
8
|
+
* forever at bootstrap-membership. Also covers the no-false-positive cases
|
|
9
|
+
* (no checkpoint, no adopted person, create-person not complete, person owned).
|
|
10
|
+
*
|
|
11
|
+
* Pure function — no network, no VaultClient. The resume command supplies the
|
|
12
|
+
* owned-person UID set from the JWT-scoped `/entity/by-type/person` call.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import type { OnboardingCheckpoint } from "@indigoai-us/hq-onboarding";
|
|
17
|
+
|
|
18
|
+
import { detectOnboardingIdentityMismatch } from "./onboard-identity-guard.js";
|
|
19
|
+
|
|
20
|
+
const OLD_PERSON = "prs_01KRGW9JQ4D2ZHD080B931ZT0J";
|
|
21
|
+
const NEW_PERSON = "prs_01NEWOWNEDBYCURRENTSIGNIN00";
|
|
22
|
+
|
|
23
|
+
function checkpoint(
|
|
24
|
+
overrides: Partial<OnboardingCheckpoint> = {},
|
|
25
|
+
): OnboardingCheckpoint {
|
|
26
|
+
return {
|
|
27
|
+
mode: "create-company",
|
|
28
|
+
startedAt: "2026-05-13T14:36:45.668Z",
|
|
29
|
+
updatedAt: "2026-06-03T14:04:32.261Z",
|
|
30
|
+
personUid: OLD_PERSON,
|
|
31
|
+
companyUid: "cmp_01KT6WSNM6HGSH3JZGS8X1KXGS",
|
|
32
|
+
companySlug: "maximus",
|
|
33
|
+
completedSteps: ["create-person", "create-company", "provision-bucket"],
|
|
34
|
+
failedStep: "bootstrap-membership",
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("detectOnboardingIdentityMismatch", () => {
|
|
40
|
+
it("flags the mismatch when the checkpoint's person is owned by a different sign-in", () => {
|
|
41
|
+
// The Jacob Wuertz case: checkpoint adopted prs_OLD (owned by the original
|
|
42
|
+
// Cognito sub), but the current caller only owns prs_NEW.
|
|
43
|
+
const result = detectOnboardingIdentityMismatch({
|
|
44
|
+
checkpoint: checkpoint(),
|
|
45
|
+
ownedPersonUids: [NEW_PERSON],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(result.kind).toBe("mismatch");
|
|
49
|
+
if (result.kind !== "mismatch") throw new Error("expected mismatch");
|
|
50
|
+
expect(result.personUid).toBe(OLD_PERSON);
|
|
51
|
+
expect(result.message).toContain(OLD_PERSON);
|
|
52
|
+
// Actionable + references the recovery paths that actually work.
|
|
53
|
+
expect(result.message).toMatch(/DEV-1695/);
|
|
54
|
+
expect(result.message).toMatch(/original/i);
|
|
55
|
+
expect(result.message).toMatch(/relink/i);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("also flags the mismatch when the caller owns NO person entities at all", () => {
|
|
59
|
+
const result = detectOnboardingIdentityMismatch({
|
|
60
|
+
checkpoint: checkpoint(),
|
|
61
|
+
ownedPersonUids: [],
|
|
62
|
+
});
|
|
63
|
+
expect(result.kind).toBe("mismatch");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("passes when the adopted person IS owned by the current caller", () => {
|
|
67
|
+
const result = detectOnboardingIdentityMismatch({
|
|
68
|
+
checkpoint: checkpoint({ personUid: NEW_PERSON }),
|
|
69
|
+
ownedPersonUids: [NEW_PERSON, "prs_01ANOTHERONE0000000000000000"],
|
|
70
|
+
});
|
|
71
|
+
expect(result).toEqual({ kind: "ok" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("passes when there is no checkpoint", () => {
|
|
75
|
+
const result = detectOnboardingIdentityMismatch({
|
|
76
|
+
checkpoint: null,
|
|
77
|
+
ownedPersonUids: [NEW_PERSON],
|
|
78
|
+
});
|
|
79
|
+
expect(result).toEqual({ kind: "ok" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("passes when the checkpoint has not adopted a person yet (no personUid)", () => {
|
|
83
|
+
const result = detectOnboardingIdentityMismatch({
|
|
84
|
+
checkpoint: checkpoint({ personUid: undefined, completedSteps: [] }),
|
|
85
|
+
ownedPersonUids: [],
|
|
86
|
+
});
|
|
87
|
+
expect(result).toEqual({ kind: "ok" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("passes when create-person is not yet complete, even with a personUid present", () => {
|
|
91
|
+
// A personUid that predates the create-person step completing is not yet
|
|
92
|
+
// the committed identity — don't false-positive on it.
|
|
93
|
+
const result = detectOnboardingIdentityMismatch({
|
|
94
|
+
checkpoint: checkpoint({ completedSteps: ["create-company"] }),
|
|
95
|
+
ownedPersonUids: [NEW_PERSON],
|
|
96
|
+
});
|
|
97
|
+
expect(result).toEqual({ kind: "ok" });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding identity-link guard (DEV-1695 / DEV-1701 / DEV-1721).
|
|
3
|
+
*
|
|
4
|
+
* Failure mode this guards against:
|
|
5
|
+
*
|
|
6
|
+
* The onboarding orchestrator's `create-person` step resolves the caller's
|
|
7
|
+
* person entity by an email-derived slug GLOBALLY (not scoped to the caller's
|
|
8
|
+
* Cognito identity). When a user signs back in under a DIFFERENT Cognito
|
|
9
|
+
* `sub` — e.g. a new Google account, an email change, or a linked-IdP sub
|
|
10
|
+
* swap — the slug lookup still finds the person row created under the ORIGINAL
|
|
11
|
+
* sub and records it in the checkpoint's `personUid`. `create-person` is then
|
|
12
|
+
* marked complete and is never re-validated against the live identity.
|
|
13
|
+
*
|
|
14
|
+
* On every `hq onboard resume`, `create-person` is skipped as "already
|
|
15
|
+
* complete" while the stale `personUid` is carried forward. The server
|
|
16
|
+
* correctly refuses to bootstrap a company membership for a person the caller
|
|
17
|
+
* does not own (it resolves the caller's person by the live Cognito sub), so
|
|
18
|
+
* resume re-fails at `bootstrap-membership` every single time — an infinite
|
|
19
|
+
* loop with a misleading downstream error.
|
|
20
|
+
*
|
|
21
|
+
* This module is the detection half of the fix: given the local checkpoint and
|
|
22
|
+
* the set of person entities ACTUALLY owned by the current caller (the server
|
|
23
|
+
* scopes `/entity/by-type/person` by the live Cognito sub), it reports whether
|
|
24
|
+
* the checkpoint adopted a person the caller no longer owns. The resume command
|
|
25
|
+
* uses it to surface a recoverable, actionable error instead of looping.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { OnboardingCheckpoint } from "@indigoai-us/hq-onboarding";
|
|
29
|
+
|
|
30
|
+
export type OnboardingIdentityCheck =
|
|
31
|
+
| { kind: "ok" }
|
|
32
|
+
| { kind: "mismatch"; personUid: string; message: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the human-facing recovery message for a detected mismatch.
|
|
36
|
+
*
|
|
37
|
+
* Only the two recovery paths that ACTUALLY work are offered. Deleting the
|
|
38
|
+
* checkpoint and re-running `create-company` does NOT help: the email-derived
|
|
39
|
+
* slug would re-adopt the same other-owned person row, so it is deliberately
|
|
40
|
+
* not suggested.
|
|
41
|
+
*/
|
|
42
|
+
function buildMismatchMessage(personUid: string): string {
|
|
43
|
+
return [
|
|
44
|
+
`Onboarding can't continue — an identity-link mismatch is blocking resume.`,
|
|
45
|
+
``,
|
|
46
|
+
`Your saved onboarding checkpoint is linked to person record ${personUid},`,
|
|
47
|
+
`but that record is owned by a different sign-in than the one you're using`,
|
|
48
|
+
`now. This usually means onboarding was started under one identity (one`,
|
|
49
|
+
`Google account / email) and later resumed under a different one.`,
|
|
50
|
+
``,
|
|
51
|
+
`Because the person record belongs to the original sign-in, resume can't`,
|
|
52
|
+
`bootstrap your company membership and would otherwise retry forever.`,
|
|
53
|
+
``,
|
|
54
|
+
`To recover, do ONE of the following:`,
|
|
55
|
+
` 1. Sign out and sign back in with your ORIGINAL onboarding identity,`,
|
|
56
|
+
` then re-run 'hq onboard resume'.`,
|
|
57
|
+
` 2. Ask an HQ admin to relink person ${personUid} to your current`,
|
|
58
|
+
` sign-in (reference Linear DEV-1695), then re-run 'hq onboard resume'.`,
|
|
59
|
+
``,
|
|
60
|
+
`Nothing was changed — your data is safe.`,
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Detect the person-entity-vs-Cognito-sub mismatch that wedges
|
|
66
|
+
* `hq onboard resume` into an infinite loop.
|
|
67
|
+
*
|
|
68
|
+
* Returns `{ kind: "ok" }` whenever the flow should proceed normally:
|
|
69
|
+
* - there is no checkpoint, or
|
|
70
|
+
* - the checkpoint never recorded an adopted person (`personUid` unset, or
|
|
71
|
+
* `create-person` not yet completed), or
|
|
72
|
+
* - the adopted person is among those owned by the current caller.
|
|
73
|
+
*
|
|
74
|
+
* Returns `{ kind: "mismatch", ... }` only when the checkpoint completed
|
|
75
|
+
* `create-person` with a `personUid` that the current caller does NOT own —
|
|
76
|
+
* the exact state that loops forever at `bootstrap-membership`.
|
|
77
|
+
*/
|
|
78
|
+
export function detectOnboardingIdentityMismatch(input: {
|
|
79
|
+
checkpoint: OnboardingCheckpoint | null;
|
|
80
|
+
ownedPersonUids: readonly string[];
|
|
81
|
+
}): OnboardingIdentityCheck {
|
|
82
|
+
const { checkpoint, ownedPersonUids } = input;
|
|
83
|
+
|
|
84
|
+
if (!checkpoint) return { kind: "ok" };
|
|
85
|
+
|
|
86
|
+
const adopted = checkpoint.personUid;
|
|
87
|
+
if (!adopted) return { kind: "ok" };
|
|
88
|
+
|
|
89
|
+
// Mirror the orchestrator's `isStepComplete`: a personUid that predates the
|
|
90
|
+
// create-person step completing has not been committed as the adopted
|
|
91
|
+
// identity yet, so don't treat it as a mismatch.
|
|
92
|
+
if (!checkpoint.completedSteps?.includes("create-person")) {
|
|
93
|
+
return { kind: "ok" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (ownedPersonUids.includes(adopted)) return { kind: "ok" };
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
kind: "mismatch",
|
|
100
|
+
personUid: adopted,
|
|
101
|
+
message: buildMismatchMessage(adopted),
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/commands/onboard.ts
CHANGED
|
@@ -22,12 +22,15 @@
|
|
|
22
22
|
import { Command } from "commander";
|
|
23
23
|
import chalk from "chalk";
|
|
24
24
|
|
|
25
|
-
import { runOnboardCli } from "@indigoai-us/hq-onboarding";
|
|
25
|
+
import { runOnboardCli, readCheckpoint } from "@indigoai-us/hq-onboarding";
|
|
26
26
|
import {
|
|
27
27
|
DEFAULT_HQ_ROOT,
|
|
28
|
+
DEFAULT_VAULT_API_URL,
|
|
28
29
|
ensureCognitoToken,
|
|
29
30
|
buildVaultConfig,
|
|
30
31
|
} from "../utils/cognito-session.js";
|
|
32
|
+
import { createDefaultVaultClient } from "./cloud-provision.js";
|
|
33
|
+
import { detectOnboardingIdentityMismatch } from "./onboard-identity-guard.js";
|
|
31
34
|
|
|
32
35
|
// ---------------------------------------------------------------------------
|
|
33
36
|
// Command registration
|
|
@@ -143,6 +146,46 @@ export function registerOnboardCommand(program: Command): void {
|
|
|
143
146
|
.action(async (options: { hqRoot: string }) => {
|
|
144
147
|
try {
|
|
145
148
|
const accessToken = await ensureCognitoToken();
|
|
149
|
+
|
|
150
|
+
// Identity-link pre-flight (DEV-1695 / DEV-1701 / DEV-1721): if the
|
|
151
|
+
// saved checkpoint adopted a person entity owned by a DIFFERENT Cognito
|
|
152
|
+
// sign-in than the current one, resume would skip the completed
|
|
153
|
+
// create-person step with that stale personUid and re-fail at
|
|
154
|
+
// bootstrap-membership forever. Detect it up front and surface a
|
|
155
|
+
// recoverable, actionable error instead of looping.
|
|
156
|
+
const checkpoint = await readCheckpoint(options.hqRoot);
|
|
157
|
+
if (checkpoint?.personUid) {
|
|
158
|
+
let ownedPersonUids: string[] | null = null;
|
|
159
|
+
try {
|
|
160
|
+
const client = createDefaultVaultClient(
|
|
161
|
+
DEFAULT_VAULT_API_URL,
|
|
162
|
+
accessToken,
|
|
163
|
+
);
|
|
164
|
+
const owned = await client.listMyPersonEntities();
|
|
165
|
+
ownedPersonUids = owned.map((p) => p.uid);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// Best-effort guard: if the pre-flight lookup itself fails (network,
|
|
168
|
+
// auth), don't block resume — but don't swallow it silently either.
|
|
169
|
+
console.warn(
|
|
170
|
+
chalk.yellow(
|
|
171
|
+
` (skipping identity pre-flight check: ${
|
|
172
|
+
err instanceof Error ? err.message : String(err)
|
|
173
|
+
})`,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (ownedPersonUids) {
|
|
178
|
+
const check = detectOnboardingIdentityMismatch({
|
|
179
|
+
checkpoint,
|
|
180
|
+
ownedPersonUids,
|
|
181
|
+
});
|
|
182
|
+
if (check.kind === "mismatch") {
|
|
183
|
+
console.error(chalk.red(`\n✗ Resume blocked:\n\n${check.message}`));
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
146
189
|
const result = await runOnboardCli({
|
|
147
190
|
mode: "resume",
|
|
148
191
|
vaultConfig: buildVaultConfig(accessToken),
|
|
@@ -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("
|
|
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
|
-
//
|
|
85
|
-
//
|
|
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.
|
|
107
|
+
expect(result).toBe(join(fakeHome, "hq"));
|
|
89
108
|
});
|
|
90
109
|
|
|
91
|
-
it("
|
|
92
|
-
// core.yaml present but no companies/ sibling →
|
|
93
|
-
// This is the synced core/ subtree's
|
|
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.
|
|
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
|
|
61
|
+
* Resolve the HQ tree root for cloud-aware subcommands (`hq sync`, `hq onboard`,
|
|
62
|
+
* `hq cloud …`, etc.).
|
|
61
63
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*
|
|
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
|
|
92
|
-
* - `'fallback'` (default) — return `~/hq` if
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* - `'throw'` — throw
|
|
96
|
-
* commands (pkg
|
|
97
|
-
*
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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),
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|