@indigoai-us/hq-cli 5.108.17 → 5.108.19

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.
@@ -50,16 +50,61 @@ export interface SyncVersionInfo {
50
50
  core: string | null;
51
51
  desktop: string | null;
52
52
  }
53
+ /**
54
+ * Per-scope manifest upload state, as `hq doctor` reads it. Structurally the
55
+ * subset of hq-cloud's `ManifestUploadStatus` this family reports, restated so
56
+ * the check is injectable without a state dir.
57
+ */
58
+ export interface SyncManifestUploadStatus {
59
+ scopeKey: string;
60
+ lastUploadAt: string | null;
61
+ snapshotId: string | null;
62
+ sequence: number;
63
+ baseUsable: boolean;
64
+ }
53
65
  /** Injectable dependencies — defaults are the real collectors. */
54
66
  export interface SyncHealthDeps {
55
67
  versions: (hqRoot: string) => SyncVersionInfo;
56
68
  journals: () => readonly SyncJournalSummary[];
57
69
  now: () => Date;
70
+ /**
71
+ * Per-scope manifest upload state (sync-reconciliation-audit US-004).
72
+ *
73
+ * Takes the ALREADY-ENUMERATED journals so the scope list the manifest check
74
+ * reports is exactly the scope list the staleness checks report — deriving it
75
+ * a second way is how the two halves of this family would end up disagreeing
76
+ * about which companies exist on the machine.
77
+ */
78
+ manifestStatuses?: (journals: readonly SyncJournalSummary[]) => readonly SyncManifestUploadStatus[];
79
+ /**
80
+ * Whether the installed hq-cloud exposes the manifest exports at all.
81
+ * Optional; defaults to feature detection. When false the family emits a
82
+ * single NA row instead of failing — an older hq-cloud is a missing
83
+ * capability, not a broken install.
84
+ */
85
+ manifestAvailable?: () => boolean;
86
+ /**
87
+ * Scopes that appear as journal shards but cannot be turned into a manifest
88
+ * scope key locally (company shards, whose `companyUid` the journal listing
89
+ * does not carry). Reported explicitly as UNTESTED rather than silently
90
+ * dropped — a scope missing from the output is indistinguishable from a
91
+ * scope that is fine.
92
+ */
93
+ unresolvedScopes?: (journals: readonly SyncJournalSummary[]) => readonly string[];
58
94
  }
59
95
  /** The versions/sync check family. Registered in `createDefaultRegistry`. */
60
96
  export declare const syncHealthFamily: CheckFamily;
61
97
  /** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
62
- export declare function checkSyncHealth(context: CheckContext, deps?: SyncHealthDeps): CheckResult[];
98
+ export declare function checkSyncHealth(context: CheckContext, rawDeps?: SyncHealthDeps): CheckResult[];
63
99
  /** True when `a` > `b` for plain X.Y.Z versions. Non-numeric parts compare 0. */
64
100
  export declare function semverGt(a: string, b: string): boolean;
101
+ /**
102
+ * A scope whose last manifest upload is older than this is reported stale.
103
+ *
104
+ * The upload pass throttles itself to one pass per scope per 24h, so anything
105
+ * under two days is simply "the throttle is working". Three days means at
106
+ * least two scheduled passes were missed, which is a corroborated signal that
107
+ * something — the daemon, the network, the kill switch — is not running.
108
+ */
109
+ export declare const STALE_MANIFEST_THRESHOLD_MS: number;
65
110
  //# sourceMappingURL=sync-health.d.ts.map
@@ -25,7 +25,12 @@
25
25
  */
26
26
  import * as fs from "node:fs";
27
27
  import * as path from "node:path";
28
- import { listJournals } from "@indigoai-us/hq-cloud";
28
+ // Namespace import, deliberately: hq-cloud is an ESM package, so a static
29
+ // NAMED import of an export the installed version lacks is a link-time
30
+ // SyntaxError that would stop `hq doctor` from starting at all. See
31
+ // ../../hq-cloud-manifest.js.
32
+ import * as hqCloud from "@indigoai-us/hq-cloud";
33
+ import { loadManifestExports, MANIFEST_UNAVAILABLE_REASON, } from "../../hq-cloud-manifest.js";
29
34
  import { CLI_VERSION } from "../../../cli-version.js";
30
35
  import { readSyncVersion } from "../../../utils/feedback-versions.js";
31
36
  import { readHqVersion } from "../../../utils/pack-contributions.js";
@@ -41,6 +46,25 @@ export const SYNC_FAMILY_TITLE = "Versions & sync";
41
46
  export const STALE_JOURNAL_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
42
47
  /** The offline update cache written by the check-hq-update SessionStart hook. */
43
48
  export const UPDATE_CACHE_RELPATH = path.join("workspace", ".hq-update-check", "last-check.json");
49
+ /** Fill in the optional dependencies so call sites never branch on undefined. */
50
+ function withDefaults(deps) {
51
+ return {
52
+ ...deps,
53
+ manifestStatuses: deps.manifestStatuses ?? defaultManifestStatuses,
54
+ manifestAvailable: deps.manifestAvailable ?? (() => loadManifestExports() !== null),
55
+ unresolvedScopes: deps.unresolvedScopes ?? defaultUnresolvedScopes,
56
+ };
57
+ }
58
+ /**
59
+ * Company journal shards are unresolvable by the default collector: the
60
+ * snapshot store keys company scopes by `companyUid`, which `listJournals()`
61
+ * does not carry. Naming them here keeps them visible.
62
+ */
63
+ function defaultUnresolvedScopes(journals) {
64
+ return journals
65
+ .filter((entry) => !NON_COMPANY_JOURNAL_SLUGS.has(entry.slug))
66
+ .map((entry) => entry.slug);
67
+ }
44
68
  function defaultVersions(hqRoot) {
45
69
  return {
46
70
  cli: CLI_VERSION,
@@ -70,9 +94,58 @@ const DEFAULT_DEPS = {
70
94
  // surface as UNKNOWN in `journalResults`, never be collapsed into the
71
95
  // empty-list (NA, "cloud sync not in use") case. False healthy is worse
72
96
  // than no check.
73
- journals: () => listJournals(),
97
+ journals: () => hqCloud.listJournals(),
74
98
  now: () => new Date(),
99
+ manifestStatuses: defaultManifestStatuses,
75
100
  };
101
+ /**
102
+ * Map the machine's journal shards onto the manifest scopes hq-cloud keys its
103
+ * snapshot store by, then read each one's last upload.
104
+ *
105
+ * The journal slug `personal` is the personal tree; every other slug is a
106
+ * company. Note the snapshot store keys company scopes by `companyUid`, which
107
+ * the journal listing does not carry — so a company whose uid we cannot name
108
+ * is simply not reported here rather than reported wrongly. That is the honest
109
+ * outcome: this check exists to tell an operator when a manifest STOPPED being
110
+ * uploaded, and inventing a scope key would make it lie in both directions.
111
+ */
112
+ function defaultManifestStatuses(journals) {
113
+ // Both the legacy `personal` shard and the current personal-vault
114
+ // pseudo-slug name the SAME personal tree; a machine mid-migration has both
115
+ // on disk, so collapse them to a single personal scope rather than asking
116
+ // hq-cloud for the same scope twice.
117
+ const scopes = journals.some((entry) => NON_COMPANY_JOURNAL_SLUGS.has(entry.slug))
118
+ ? [{ kind: "personal" }]
119
+ : [];
120
+ if (scopes.length === 0)
121
+ return [];
122
+ // Absent on an older hq-cloud; the NA row is emitted by `manifestResults`
123
+ // before this ever runs, so returning empty here is belt-and-braces.
124
+ const exports = loadManifestExports();
125
+ if (!exports)
126
+ return [];
127
+ return exports.readManifestUploadStatus(exports.getStateDir(), scopes);
128
+ }
129
+ /** The legacy journal slug that named the personal (non-company) tree. */
130
+ const PERSONAL_SCOPE_SLUG = "personal";
131
+ /**
132
+ * hq-cloud's personal-vault pseudo-slug (`PERSONAL_VAULT_JOURNAL_SLUG`). It is
133
+ * a sentinel, not a company: the entity service never mints a slug with
134
+ * leading underscores. Inlined rather than imported so this module keeps its
135
+ * link-safety story (see `hq-cloud-manifest.ts`).
136
+ */
137
+ const PERSONAL_VAULT_SCOPE_SLUG = "__hq_personal_vault__";
138
+ /**
139
+ * Journal slugs that do NOT name a company. Every one of these is already
140
+ * covered by the `sync.manifest.personal` row, so listing them as unresolved
141
+ * company scopes would be doubly wrong: it invents a company that does not
142
+ * exist and tells the operator to run `hq sync manifest --scope
143
+ * __hq_personal_vault__`, which is not a scope the CLI accepts.
144
+ */
145
+ const NON_COMPANY_JOURNAL_SLUGS = new Set([
146
+ PERSONAL_SCOPE_SLUG,
147
+ PERSONAL_VAULT_SCOPE_SLUG,
148
+ ]);
76
149
  /** The versions/sync check family. Registered in `createDefaultRegistry`. */
77
150
  export const syncHealthFamily = {
78
151
  id: SYNC_FAMILY_ID,
@@ -80,12 +153,13 @@ export const syncHealthFamily = {
80
153
  run: (context) => Promise.resolve(checkSyncHealth(context)),
81
154
  };
82
155
  /** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
83
- export function checkSyncHealth(context, deps = DEFAULT_DEPS) {
156
+ export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
157
+ const deps = withDefaults(rawDeps);
84
158
  try {
85
159
  return [
86
160
  ...versionResults(context, deps),
87
161
  ...updateAvailabilityResult(context, deps),
88
- ...journalResults(deps),
162
+ ...journalAndManifestResults(deps),
89
163
  ];
90
164
  }
91
165
  catch (error) {
@@ -204,7 +278,16 @@ export function semverGt(a, b) {
204
278
  return false;
205
279
  }
206
280
  // ─── Per-journal staleness ───────────────────────────────────────────────────
207
- function journalResults(deps) {
281
+ /**
282
+ * Enumerate the journals ONCE, then run both the staleness checks and the
283
+ * manifest-upload checks off that single list.
284
+ *
285
+ * Sharing the enumeration is not just an optimisation: an enumeration failure
286
+ * must produce exactly one UNKNOWN, and the two checks must never disagree
287
+ * about which scopes exist because they asked the store at two different
288
+ * moments.
289
+ */
290
+ function journalAndManifestResults(deps) {
208
291
  // Enumeration failure (throw / IO error) is UNKNOWN — which FAILS per the
209
292
  // doctor's exit-code contract — never NA: a broken or unreadable journal
210
293
  // store must not read as "cloud sync not in use" (a false healthy). Only a
@@ -235,6 +318,9 @@ function journalResults(deps) {
235
318
  },
236
319
  ];
237
320
  }
321
+ return [...journalResults(deps, journals), ...manifestResults(deps, journals)];
322
+ }
323
+ function journalResults(deps, journals) {
238
324
  const nowMs = deps.now().getTime();
239
325
  return journals.map((entry) => {
240
326
  const lastSync = entry.journal?.lastSync;
@@ -276,4 +362,125 @@ function journalResults(deps) {
276
362
  };
277
363
  });
278
364
  }
365
+ // ─── Manifest upload freshness (sync-reconciliation-audit US-004) ────────────
366
+ /**
367
+ * A scope whose last manifest upload is older than this is reported stale.
368
+ *
369
+ * The upload pass throttles itself to one pass per scope per 24h, so anything
370
+ * under two days is simply "the throttle is working". Three days means at
371
+ * least two scheduled passes were missed, which is a corroborated signal that
372
+ * something — the daemon, the network, the kill switch — is not running.
373
+ */
374
+ export const STALE_MANIFEST_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000;
375
+ /**
376
+ * Report the last manifest upload per scope.
377
+ *
378
+ * NEVER-UPLOADED IS INFORMATIONAL, NOT A FAILURE. The audit rolls out to a
379
+ * fleet that has never run a pass, and every one of those machines is working
380
+ * exactly as designed until the first pass is due. Reporting UNTESTED (wired
381
+ * but not yet exercised) rather than WARN is the whole reason the doctor's
382
+ * status vocabulary has that value — collapsing it into WARN would turn the
383
+ * first day of the rollout into a fleet-wide false alarm.
384
+ */
385
+ function manifestResults(deps, journals) {
386
+ // An hq-cloud that predates the manifest exports is a MISSING CAPABILITY,
387
+ // not a fault: one NA row, and the rest of `hq doctor` runs untouched.
388
+ if (!deps.manifestAvailable()) {
389
+ return [
390
+ {
391
+ status: "NA",
392
+ checkId: "sync.manifest",
393
+ message: `${MANIFEST_UNAVAILABLE_REASON} — upgrade hq-cloud to audit manifest uploads.`,
394
+ },
395
+ ];
396
+ }
397
+ let statuses;
398
+ try {
399
+ statuses = deps.manifestStatuses(journals);
400
+ }
401
+ catch (error) {
402
+ return [
403
+ {
404
+ status: "UNKNOWN",
405
+ checkId: "sync.manifest",
406
+ message: `Manifest upload state could not be read: ${error instanceof Error ? error.message : String(error)}`,
407
+ remediation: "Check that the HQ state directory is readable, then re-run `hq doctor`.",
408
+ },
409
+ ];
410
+ }
411
+ const unresolved = unresolvedResults(deps, journals);
412
+ if (statuses.length === 0) {
413
+ if (unresolved.length > 0)
414
+ return unresolved;
415
+ return [
416
+ {
417
+ status: "NA",
418
+ checkId: "sync.manifest",
419
+ message: "No manifest-auditable sync scopes on this machine — nothing to reconcile.",
420
+ },
421
+ ];
422
+ }
423
+ const nowMs = deps.now().getTime();
424
+ const reported = statuses.map((entry) => {
425
+ const checkId = `sync.manifest.${entry.scopeKey}`;
426
+ if (!entry.lastUploadAt) {
427
+ return {
428
+ status: "UNTESTED",
429
+ checkId,
430
+ message: `Scope '${entry.scopeKey}' has never uploaded a sync manifest — expected until the first pass runs.`,
431
+ remediation: "Run `hq sync manifest` to upload one now.",
432
+ };
433
+ }
434
+ const parsed = Date.parse(entry.lastUploadAt);
435
+ if (!Number.isFinite(parsed)) {
436
+ return {
437
+ status: "UNKNOWN",
438
+ checkId,
439
+ message: `Scope '${entry.scopeKey}' has an unparseable last manifest upload time (${entry.lastUploadAt}).`,
440
+ };
441
+ }
442
+ const ageMs = nowMs - parsed;
443
+ const baseNote = entry.baseUsable
444
+ ? `delta base ${entry.snapshotId ?? "?"}`
445
+ : "next pass will send a full manifest";
446
+ if (ageMs > STALE_MANIFEST_THRESHOLD_MS) {
447
+ const days = Math.floor(ageMs / 86_400_000);
448
+ return {
449
+ status: "WARN",
450
+ checkId,
451
+ message: `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt}) — the daily audit pass is not running.`,
452
+ remediation: "Run `hq sync manifest` and check HQ_SYNC_MANIFEST_DISABLED and the sync runner.",
453
+ };
454
+ }
455
+ return {
456
+ status: "PASS",
457
+ checkId,
458
+ message: `Scope '${entry.scopeKey}' uploaded a sync manifest at ${entry.lastUploadAt} (sequence ${entry.sequence}, ${baseNote}).`,
459
+ };
460
+ });
461
+ return [...reported, ...unresolved];
462
+ }
463
+ /**
464
+ * One explicit row per scope we could NOT resolve to a manifest scope key.
465
+ *
466
+ * UNTESTED, not NA and certainly not silence: the scope exists on this machine
467
+ * and is genuinely un-audited, so an operator must be able to see that the
468
+ * doctor did not check it. Silently skipping is how a company stops being
469
+ * reconciled without anyone noticing.
470
+ */
471
+ function unresolvedResults(deps, journals) {
472
+ let slugs;
473
+ try {
474
+ slugs = deps.unresolvedScopes(journals);
475
+ }
476
+ catch {
477
+ return [];
478
+ }
479
+ return slugs.map((slug) => ({
480
+ status: "UNTESTED",
481
+ checkId: `sync.manifest.unresolved.${slug}`,
482
+ message: `Company scope '${slug}' has a sync journal but its companyUid could not be resolved locally — its manifest uploads were not checked.`,
483
+ remediation: `Run \`hq sync manifest --scope ${slug}\` to audit it explicitly.`,
484
+ }));
485
+ }
279
486
  //# sourceMappingURL=sync-health.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Feature detection for the hq-cloud manifest exports.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ * --------------------
6
+ * `runManifestUploadPass` / `readManifestUploadStatus` were added in the
7
+ * hq-cloud sync-reconciliation-audit branch and are NOT in the currently
8
+ * published `~6.16.6` line that this CLI pins. hq-cloud is an ES module, so a
9
+ * static named import of an export that does not exist is a LINK-TIME
10
+ * SyntaxError: the importing module never evaluates, and every command that
11
+ * transitively imports it dies at startup. `hq doctor` is one of those
12
+ * commands, and it is one of the most widely used in the CLI — a doctor that
13
+ * cannot start is strictly worse than a doctor missing one row.
14
+ *
15
+ * So nothing imports those symbols by name. We import the module NAMESPACE
16
+ * (always link-safe, regardless of which exports exist) and feature-detect the
17
+ * functions on it. Callers that need them ask for them and degrade when they
18
+ * are absent:
19
+ * - `hq doctor` emits a single NA row ("manifest status unavailable");
20
+ * - `hq sync manifest` prints the version requirement and exits non-zero.
21
+ *
22
+ * Once the hq-cloud release carrying these exports lands and the pin is bumped,
23
+ * this module keeps working unchanged — detection simply always succeeds.
24
+ */
25
+ /**
26
+ * The first hq-cloud release that carries the manifest upload exports.
27
+ *
28
+ * 6.16.23 is the release that actually ships `runManifestUploadPass` /
29
+ * `readManifestUploadStatus`, and is the version this package pins.
30
+ *
31
+ * Used only for the human-facing "you need at least X" message; the actual
32
+ * gate is capability detection, never a version comparison — a version string
33
+ * can lie about what a build contains, a function reference cannot. So a stale
34
+ * value here misinforms, but never misgates.
35
+ */
36
+ export declare const MANIFEST_MIN_HQ_CLOUD_VERSION = "6.16.23";
37
+ /** One-line explanation shared by every degradation path. */
38
+ export declare const MANIFEST_UNAVAILABLE_REASON: string;
39
+ /** The subset of hq-cloud the manifest paths need, once proven present. */
40
+ export interface ManifestExports {
41
+ runManifestUploadPass: (options: never) => unknown;
42
+ readManifestUploadStatus: (...args: never[]) => unknown;
43
+ getStateDir: () => string;
44
+ }
45
+ /** Thrown by {@link requireManifestExports}. Carries a user-ready message. */
46
+ export declare class ManifestUnsupportedError extends Error {
47
+ constructor(message?: string);
48
+ }
49
+ /**
50
+ * Resolve the manifest exports, or null when the installed hq-cloud predates
51
+ * them.
52
+ *
53
+ * The module object is injectable so the absent-export path is testable
54
+ * without mocking the package (and so the test cannot accidentally pass merely
55
+ * because the local `link:` override happens to be new enough).
56
+ */
57
+ export declare function loadManifestExports(mod?: Record<string, unknown>): ManifestExports | null;
58
+ /** True when the installed hq-cloud can do manifest passes at all. */
59
+ export declare function manifestExportsAvailable(mod?: Record<string, unknown>): boolean;
60
+ /** Like {@link loadManifestExports}, but throws instead of returning null. */
61
+ export declare function requireManifestExports(mod?: Record<string, unknown>): ManifestExports;
62
+ //# sourceMappingURL=hq-cloud-manifest.d.ts.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Feature detection for the hq-cloud manifest exports.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ * --------------------
6
+ * `runManifestUploadPass` / `readManifestUploadStatus` were added in the
7
+ * hq-cloud sync-reconciliation-audit branch and are NOT in the currently
8
+ * published `~6.16.6` line that this CLI pins. hq-cloud is an ES module, so a
9
+ * static named import of an export that does not exist is a LINK-TIME
10
+ * SyntaxError: the importing module never evaluates, and every command that
11
+ * transitively imports it dies at startup. `hq doctor` is one of those
12
+ * commands, and it is one of the most widely used in the CLI — a doctor that
13
+ * cannot start is strictly worse than a doctor missing one row.
14
+ *
15
+ * So nothing imports those symbols by name. We import the module NAMESPACE
16
+ * (always link-safe, regardless of which exports exist) and feature-detect the
17
+ * functions on it. Callers that need them ask for them and degrade when they
18
+ * are absent:
19
+ * - `hq doctor` emits a single NA row ("manifest status unavailable");
20
+ * - `hq sync manifest` prints the version requirement and exits non-zero.
21
+ *
22
+ * Once the hq-cloud release carrying these exports lands and the pin is bumped,
23
+ * this module keeps working unchanged — detection simply always succeeds.
24
+ */
25
+ import * as hqCloud from "@indigoai-us/hq-cloud";
26
+ /**
27
+ * The first hq-cloud release that carries the manifest upload exports.
28
+ *
29
+ * 6.16.23 is the release that actually ships `runManifestUploadPass` /
30
+ * `readManifestUploadStatus`, and is the version this package pins.
31
+ *
32
+ * Used only for the human-facing "you need at least X" message; the actual
33
+ * gate is capability detection, never a version comparison — a version string
34
+ * can lie about what a build contains, a function reference cannot. So a stale
35
+ * value here misinforms, but never misgates.
36
+ */
37
+ export const MANIFEST_MIN_HQ_CLOUD_VERSION = "6.16.23";
38
+ /** One-line explanation shared by every degradation path. */
39
+ export const MANIFEST_UNAVAILABLE_REASON = `manifest status unavailable: hq-cloud too old ` +
40
+ `(requires an hq-cloud release with manifest support ` +
41
+ `(>= ${MANIFEST_MIN_HQ_CLOUD_VERSION}))`;
42
+ /** Thrown by {@link requireManifestExports}. Carries a user-ready message. */
43
+ export class ManifestUnsupportedError extends Error {
44
+ constructor(message = MANIFEST_UNAVAILABLE_REASON) {
45
+ super(message);
46
+ this.name = "ManifestUnsupportedError";
47
+ }
48
+ }
49
+ /**
50
+ * Resolve the manifest exports, or null when the installed hq-cloud predates
51
+ * them.
52
+ *
53
+ * The module object is injectable so the absent-export path is testable
54
+ * without mocking the package (and so the test cannot accidentally pass merely
55
+ * because the local `link:` override happens to be new enough).
56
+ */
57
+ export function loadManifestExports(mod = hqCloud) {
58
+ const runManifestUploadPass = mod.runManifestUploadPass;
59
+ const readManifestUploadStatus = mod.readManifestUploadStatus;
60
+ const getStateDir = mod.getStateDir;
61
+ if (typeof runManifestUploadPass !== "function" ||
62
+ typeof readManifestUploadStatus !== "function" ||
63
+ typeof getStateDir !== "function") {
64
+ return null;
65
+ }
66
+ return {
67
+ runManifestUploadPass,
68
+ readManifestUploadStatus,
69
+ getStateDir,
70
+ };
71
+ }
72
+ /** True when the installed hq-cloud can do manifest passes at all. */
73
+ export function manifestExportsAvailable(mod) {
74
+ return loadManifestExports(mod) !== null;
75
+ }
76
+ /** Like {@link loadManifestExports}, but throws instead of returning null. */
77
+ export function requireManifestExports(mod) {
78
+ const exports = loadManifestExports(mod);
79
+ if (!exports)
80
+ throw new ManifestUnsupportedError();
81
+ return exports;
82
+ }
83
+ //# sourceMappingURL=hq-cloud-manifest.js.map
package/dist/main.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
7
7
  import "./node-preflight.js";
8
8
  import "./node-network-compat.js";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
9
11
  import { Command } from "commander";
10
12
  import { initSentry, Sentry } from "./sentry.js";
11
13
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
@@ -46,10 +48,19 @@ import { settleWithin } from "./utils/settle-with-timeout.js";
46
48
  import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
47
49
  import { kickFlagRegistryReadiness } from "./lib/flag-registry.js";
48
50
  import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
51
+ import { registerCommandsWithRecovery } from "./startup-registration.js";
52
+ import { installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTreeTornError, } from "./utils/install-tree-torn.js";
49
53
  import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
50
54
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
51
55
  /** Hard upper bound for non-user-visible release-health finalization. */
52
56
  const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
57
+ /**
58
+ * The RUNNING install's own entrypoint (`<pkg>/dist/index.js`), used as the
59
+ * torn-install recovery re-exec target. It must be this resolved path — never
60
+ * `hq` from PATH — because a global reinstall leaves `bin/hq` absent for much of
61
+ * the rewrite window, while this path now holds the settled tree.
62
+ */
63
+ const RUNNING_ENTRY_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
53
64
  const defaultStreamErrorDependencies = {
54
65
  stderr: process.stderr,
55
66
  exit: (code) => process.exit(code),
@@ -160,13 +171,31 @@ export async function runCli() {
160
171
  // `--help`, a bare `hq`, an unknown command, any command not on the
161
172
  // manifest — falls back to the complete graph, so its behaviour is
162
173
  // unchanged. See register-all.ts for the measurements that motivated this.
163
- const lazy = findLazyCommand(process.argv);
164
- if (lazy) {
165
- await lazy.register(program);
166
- }
167
- else {
168
- const { registerAllCommands } = await import("./register-all.js");
169
- registerAllCommands(program);
174
+ // The same lazy/full registration as before, wrapped so it recovers ONCE if
175
+ // a global reinstall is tearing the install tree out from under these
176
+ // deferred imports (Sentry HQ-CLI-1G/1H/1J/1K). Which modules are imported,
177
+ // and in what order, is unchanged — only the failure handling is added.
178
+ const registration = await registerCommandsWithRecovery({
179
+ register: async () => {
180
+ const lazy = findLazyCommand(process.argv);
181
+ if (lazy) {
182
+ await lazy.register(program);
183
+ }
184
+ else {
185
+ const { registerAllCommands } = await import("./register-all.js");
186
+ registerAllCommands(program);
187
+ }
188
+ },
189
+ argv: process.argv,
190
+ env: process.env,
191
+ entryPath: RUNNING_ENTRY_PATH,
192
+ stderr: process.stderr,
193
+ });
194
+ if (registration.reexecStatus !== undefined) {
195
+ // The re-exec'd child ran the command on the settled tree; carry its exit
196
+ // status out (after the finally block), exactly as the self-update re-exec.
197
+ reexecStatus = registration.reexecStatus;
198
+ return;
170
199
  }
171
200
  await program.parseAsync();
172
201
  }
@@ -337,6 +366,21 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
337
366
  });
338
367
  deps.setExitCode(1);
339
368
  }
369
+ else if (isInstallTreeTornError(err)) {
370
+ // HQ-CLI-1G/1H/1J/1K: a global reinstall tore the install tree out from
371
+ // under a starting `hq` and the bounded settle-wait + single re-exec could
372
+ // not recover it (a still-incomplete tree, a foreign updater, or a failed
373
+ // re-exec). Print the fixed reinstall remedy naming the bounded missing
374
+ // specifier, and STILL capture WITH bounded diagnostics — a genuinely
375
+ // pruned or incomplete install must stay visible. Mirrors the package-root
376
+ // branch's print-and-capture shape; a class-instance check disjoint from
377
+ // every neighbour, so no existing ordering changes. A SUCCESSFUL recovery
378
+ // never reaches here (it returns the child's status), so this line and this
379
+ // capture only ever mark a real, unrecovered break.
380
+ deps.stderr.write(`hq: ${installTreeTornStderrLine(err)}\n`);
381
+ deps.sentry.captureException(err, installTreeTornCaptureContext(err));
382
+ deps.setExitCode(1);
383
+ }
340
384
  else if (isVaultAccessDeniedError(err)) {
341
385
  // ARM B (Sentry 7709408531): `hq skill create`'s post-register vault sync
342
386
  // was DENIED by S3 — an AWS SDK v3 403 from an hq-pro IAM session-policy
@@ -33,6 +33,7 @@ import { registerUpdateCommand } from "./commands/update.js";
33
33
  import { registerCloudCommands } from "./commands/cloud.js";
34
34
  import { registerSyncModeCommand } from "./commands/sync-mode.js";
35
35
  import { registerSyncNarrowCommand } from "./commands/sync-narrow.js";
36
+ import { registerSyncManifestCommand } from "./commands/sync-manifest.js";
36
37
  import { registerCloudProvisionCommands } from "./commands/cloud-provision.js";
37
38
  import { registerCloudDemoteCommands } from "./commands/cloud-demote.js";
38
39
  import { registerLoginCommand } from "./commands/login.js";
@@ -121,6 +122,7 @@ export function registerAllCommands(program) {
121
122
  registerCloudCommands(syncCmd);
122
123
  registerSyncModeCommand(syncCmd);
123
124
  registerSyncNarrowCommand(syncCmd);
125
+ registerSyncManifestCommand(syncCmd);
124
126
  // Cloud provisioning subcommand group (entity + bucket + initial sync)
125
127
  // Distinct from `hq sync` which assumes provisioning has already happened.
126
128
  const cloudCmd = program
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The command-registration recovery seam for the "install tree torn out from
3
+ * under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K).
4
+ *
5
+ * `runCli` registers commands as its last step before `program.parseAsync()` —
6
+ * the lazy command import or `import("./register-all.js")` and everything those
7
+ * pull in. That is the ONE place a module-resolution failure is provably safe to
8
+ * recover from: it is inside hq-cli's own graph, it runs before `parseAsync` and
9
+ * before the preAction hook, so no command action, telemetry, or stdin read has
10
+ * happened yet, and a single re-exec can therefore never run a command twice.
11
+ *
12
+ * This wraps the existing `register()` call with exactly that recovery, driven by
13
+ * the classifier / probe / settle wait / carrier in ./utils/install-tree-torn.ts.
14
+ * It changes nothing about WHICH modules are imported or in what order — only the
15
+ * failure handling around the existing imports. Dependency-injected in the house
16
+ * style of handleTopLevelError so the whole seam is unit-testable without real
17
+ * spawns, waits, or a real install tree.
18
+ */
19
+ import { type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
20
+ /**
21
+ * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
22
+ * from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
23
+ * shadow each other.
24
+ */
25
+ export declare const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
26
+ /** Operator override for the settle wait's deadline. */
27
+ export declare const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
28
+ /** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
29
+ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
30
+ /**
31
+ * The single dim, informational line emitted when — and only when — a wait
32
+ * actually happens. It tells a human why the command paused and is the e2e's
33
+ * synchronization point; it is never a remedy and never sets an error exit code.
34
+ */
35
+ export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
36
+ /** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
37
+ export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
38
+ /** Minimal view of a `spawnSync` result the seam relies on. */
39
+ export interface RecoverySpawnResult {
40
+ status: number | null;
41
+ error?: Error;
42
+ }
43
+ export interface RegisterRecoveryDeps {
44
+ spawn?: (command: string, args: string[], options: {
45
+ stdio: "inherit";
46
+ env: NodeJS.ProcessEnv;
47
+ }) => RecoverySpawnResult;
48
+ resolveInstall?: () => {
49
+ packageRoot: string | null;
50
+ };
51
+ lockPath?: () => string;
52
+ waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
53
+ /** node flags to forward to the re-exec child (defaults to process.execArgv). */
54
+ execArgv?: readonly string[];
55
+ }
56
+ export interface RegisterCommandsWithRecoveryArgs {
57
+ /** Runs the existing registration (lazy command import or register-all import). */
58
+ register: () => Promise<void>;
59
+ argv: readonly string[];
60
+ env: NodeJS.ProcessEnv;
61
+ /** The RUNNING install's own `dist/index.js` — the safe re-exec target. */
62
+ entryPath: string;
63
+ stderr: Pick<typeof process.stderr, "write">;
64
+ deps?: RegisterRecoveryDeps;
65
+ }
66
+ export interface RegisterCommandsWithRecoveryResult {
67
+ /** Set only when a re-exec ran: its exit status becomes this invocation's. */
68
+ reexecStatus?: number;
69
+ }
70
+ /**
71
+ * Register commands, recovering once from a torn-install module-resolution
72
+ * failure. On a clean registration this returns `{}` and touches nothing else.
73
+ * On a classified failure it waits (bounded) for the install to settle and
74
+ * re-execs the running install's own entrypoint once; a child (guarded), an
75
+ * unsettled wait, or a failed spawn each throws an {@link InstallTreeTornError}
76
+ * carrying bounded diagnostics so a genuinely broken install stays visible.
77
+ */
78
+ export declare function registerCommandsWithRecovery(args: RegisterCommandsWithRecoveryArgs): Promise<RegisterCommandsWithRecoveryResult>;
79
+ //# sourceMappingURL=startup-registration.d.ts.map