@indigoai-us/hq-cli 5.108.18 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.19] — 2026-09-07
6
+
5
7
  ## [5.108.18] — 2026-09-07
6
8
 
7
9
  ### Fixed
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `hq sync manifest` (sync-reconciliation-audit US-004) — the CLI half of the
3
+ * shared client-side manifest upload pass.
4
+ *
5
+ * WHAT THIS FILE OWNS, AND WHAT IT DELIBERATELY DOES NOT
6
+ * -----------------------------------------------------
7
+ * All of the SAFETY rules for an upload pass (sequence monotonicity, when a
8
+ * snapshot may become a delta base, `resend_full` handling, chunk-header
9
+ * identity, the 24h throttle, the per-scope lock) live in hq-cloud's
10
+ * {@link runManifestUploadPass}. Duplicating any of them here would let the
11
+ * CLI and the hq-sync daemon drift, and the failure mode of that drift is
12
+ * silent server-side corruption of a scope's materialised view — the exact
13
+ * thing this audit exists to detect.
14
+ *
15
+ * This file therefore owns only the three things hq-cloud refuses to own:
16
+ *
17
+ * 1. **Auth + base URL.** The transport is injected; we build it from the
18
+ * CLI's own Cognito session and `vaultApiFetch`'s base-URL resolution.
19
+ * 2. **Scope resolution.** `personal` vs. a company SLUG → the `companyUid`
20
+ * the wire contract wants, resolved the same way `hq sync mode` does
21
+ * (memberships + `entity.get`), so both commands agree on what
22
+ * `--company frogbear` means.
23
+ * 3. **Human output + exit codes.** The seam never throws for a runtime
24
+ * condition; it returns a status. Only `failed` is a non-zero exit —
25
+ * `disabled`, `throttled`, `soft_skipped` and `locked` are all normal,
26
+ * expected outcomes of asking for a pass, and exiting non-zero on them
27
+ * would make the command unusable from a wrapper script.
28
+ *
29
+ * IDENTITY JOIN
30
+ * -------------
31
+ * `installationId` comes from {@link loadClientHealthState} — the SAME value
32
+ * the client-health heartbeat reports. Minting a second id here would produce
33
+ * manifest rows that cannot be joined to the heartbeat rows for the same
34
+ * machine, which is most of the value of the audit.
35
+ *
36
+ * Cross-package note: this command depends on `runManifestUploadPass` /
37
+ * `readManifestUploadStatus`, added in the hq-cloud sync-reconciliation-audit
38
+ * branch and NOT yet published. While that is true, package.json carries a
39
+ * `pnpm.overrides` link to the local hq-cloud worktree; that override must be
40
+ * removed and the `@indigoai-us/hq-cloud` pin bumped from `~6.16.6` to the
41
+ * release containing these exports before this branch can merge.
42
+ */
43
+ import { Command } from "commander";
44
+ import type { ManifestUploadTransport, RunManifestUploadPassOptions, RunManifestUploadPassResult } from "@indigoai-us/hq-cloud";
45
+ /** The server route the pass POSTs each chunk to. */
46
+ export declare const MANIFEST_UPLOAD_PATH = "/v1/sync-manifest/upload";
47
+ /**
48
+ * The kill switch hq-cloud's pass honours. Restated locally (rather than
49
+ * imported) for two reasons: it lives in the same unreleased module as the
50
+ * manifest exports, and — more importantly — we must be able to read it
51
+ * BEFORE deciding to do any work at all.
52
+ */
53
+ export declare const MANIFEST_DISABLED_ENV = "HQ_SYNC_MANIFEST_DISABLED";
54
+ /** True when the kill switch is set to anything other than empty/0/false. */
55
+ export declare function manifestUploadsDisabled(env?: NodeJS.ProcessEnv): boolean;
56
+ /** The literal `--scope` value that selects the personal (non-company) tree. */
57
+ export declare const PERSONAL_SCOPE = "personal";
58
+ /**
59
+ * The scope shape the upload pass wants. Structurally identical to hq-cloud's
60
+ * `BuildManifestScope`, restated locally so the resolver is unit-testable
61
+ * without importing the builder's whole module graph.
62
+ */
63
+ export interface ResolvedManifestScope {
64
+ kind: "personal" | "company";
65
+ companyUid?: string;
66
+ slug?: string;
67
+ }
68
+ /** Subset of `VaultClient` the slug → uid resolution needs (test seam). */
69
+ export interface SyncManifestVaultClient {
70
+ listMyMemberships(): Promise<{
71
+ companyUid: string;
72
+ }[]>;
73
+ entity: {
74
+ get(uid: string): Promise<{
75
+ uid: string;
76
+ slug: string;
77
+ }>;
78
+ };
79
+ }
80
+ /**
81
+ * Everything the orchestrator touches that is not a pure function. Injected so
82
+ * the command is testable without a Cognito session, a network, an HQ tree, or
83
+ * (critically) the real per-scope lock the seam takes under `~/.hq/locks`.
84
+ */
85
+ export interface SyncManifestDeps {
86
+ /** hq-cloud's shared upload pass. */
87
+ runPass: (options: RunManifestUploadPassOptions) => Promise<RunManifestUploadPassResult>;
88
+ /** Resolves a company slug to its uid. Only called for company scopes. */
89
+ resolveCompanyUid: (slug: string) => Promise<string>;
90
+ /** The HQ state dir holding the client-health state and snapshot store. */
91
+ stateDir: () => string;
92
+ /** The stable installation identity, shared with the health heartbeat. */
93
+ installationId: (stateDir: string) => string;
94
+ /** This machine's stable, non-fingerprint id. */
95
+ machineId: () => string;
96
+ /** Builds the authed transport. Not called on `--print` (nothing is sent). */
97
+ transport: () => Promise<ManifestUploadTransport>;
98
+ /** stdout sink — captured in tests. */
99
+ log: (line: string) => void;
100
+ }
101
+ export interface RunSyncManifestOptions {
102
+ scopeArg?: string;
103
+ hqRoot: string;
104
+ full?: boolean;
105
+ print?: boolean;
106
+ /**
107
+ * Honour hq-cloud's 24h per-scope throttle. Off by default: see
108
+ * {@link runSyncManifest}. Set by `--respect-throttle`, which is what a cron
109
+ * wrapper wants — it makes the CLI behave like the daemon's cadence instead
110
+ * of forcing a pass on every tick.
111
+ */
112
+ respectThrottle?: boolean;
113
+ }
114
+ export interface RunSyncManifestOutcome {
115
+ result: RunManifestUploadPassResult;
116
+ scope: ResolvedManifestScope;
117
+ /** Process exit code — non-zero ONLY for a genuine `failed` pass. */
118
+ exitCode: number;
119
+ }
120
+ /**
121
+ * Resolve `--scope` into the wire scope.
122
+ *
123
+ * `personal` is the literal; anything else is a company SLUG, which must be
124
+ * turned into the server's `companyUid` — the contract carries the uid, while
125
+ * the local walk needs the slug (it names `companies/{slug}` and the journal
126
+ * shard), so BOTH end up on the resolved scope.
127
+ *
128
+ * With no `--scope`, we fall back to the active company recorded in
129
+ * `<hq-root>/.hq/config.json` (the same source `hq sync mode` uses), and to
130
+ * `personal` when there is no active company — a CLI-only install with no
131
+ * company still has a personal tree worth auditing.
132
+ */
133
+ export declare function resolveManifestScope(scopeArg: string | undefined, hqRoot: string, resolveCompanyUid: (slug: string) => Promise<string>): Promise<ResolvedManifestScope>;
134
+ /**
135
+ * A stable, human-meaningless machine id.
136
+ *
137
+ * Precedence mirrors the rest of the CLI's "ask the installer first" habit:
138
+ * the id HQ Sync recorded in `~/.hq/menubar.json` if there is one (so the
139
+ * daemon's manifests and the CLI's manifests report the SAME machine), then a
140
+ * sanitised hostname, then a constant. It is explicitly not a hardware
141
+ * fingerprint — the contract only needs it to be stable and opaque-ish.
142
+ */
143
+ export declare function resolveMachineId(hqConfigDir?: string, hostname?: () => string): string;
144
+ /**
145
+ * Coerce to the contract's identifier charset (`[A-Za-z0-9][A-Za-z0-9_.:-]*`,
146
+ * 2..64). Returns undefined when nothing usable survives, so the caller can
147
+ * fall through rather than send a value the server will reject.
148
+ */
149
+ export declare function sanitiseIdentifier(raw: string): string | undefined;
150
+ /** Human label for a resolved scope — what the summary line names. */
151
+ export declare function describeScope(scope: ResolvedManifestScope): string;
152
+ /**
153
+ * One-line-per-fact summary of a non-print pass.
154
+ *
155
+ * Every non-`failed` status gets an explanatory sentence rather than a bare
156
+ * enum: `throttled` and `soft_skipped` in particular are the statuses a user
157
+ * is most likely to see and most likely to misread as a failure.
158
+ */
159
+ export declare function formatManifestSummary(result: RunManifestUploadPassResult, scope: ResolvedManifestScope): string;
160
+ /**
161
+ * POST one chunk with the caller's Cognito bearer.
162
+ *
163
+ * It RESOLVES for every HTTP answer, including 4xx/5xx: the seam classifies
164
+ * status codes itself (a 200 can still carry a protocol-level `resend_full`,
165
+ * and a 409 means something quite specific), so throwing here would collapse
166
+ * all of that into an undifferentiated `transport_error`. A body that will not
167
+ * parse as JSON is passed through as `undefined` rather than failing the pass
168
+ * — the status is the load-bearing part.
169
+ */
170
+ export declare function createManifestTransport(token: string): ManifestUploadTransport;
171
+ /**
172
+ * Read (and, when it had to be minted, persist) the installation id shared
173
+ * with the client-health heartbeat. Persisting matters: `loadClientHealthState`
174
+ * degrades a missing/corrupt file to a FRESH id, so a first run that did not
175
+ * write it back would report a different installation on every invocation and
176
+ * the manifest rows would never join to anything.
177
+ */
178
+ export declare function defaultInstallationId(stateDir: string): string;
179
+ /** Max concurrent `entity.get` calls when resolving a slug to a companyUid. */
180
+ export declare const MEMBERSHIP_RESOLVE_CONCURRENCY = 4;
181
+ /**
182
+ * `Promise.all`-shaped map with a hard ceiling on in-flight work. Results keep
183
+ * input order, so callers can still index by position.
184
+ */
185
+ export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]>;
186
+ /**
187
+ * Production wiring. Built lazily per invocation because both the vault client
188
+ * and the transport need a Cognito token, and `--print` must work without one
189
+ * (it uploads nothing, so demanding a login would be gratuitous).
190
+ */
191
+ export declare function createDefaultDeps(): SyncManifestDeps;
192
+ /**
193
+ * Resolve the scope, run one upload pass, print, and decide the exit code.
194
+ *
195
+ * `--print` short-circuits the transport entirely (it is never built, so no
196
+ * token is required) and hands the seam `dryRun: true`, which builds the
197
+ * chunks without persisting a snapshot. The chunks go to stdout as JSON and
198
+ * nothing else is printed, so the output is pipeable into `jq`.
199
+ *
200
+ * An explicit invocation passes `ignoreThrottle: true`: a human typing
201
+ * `hq sync manifest` has asked for a pass NOW, and the 24h throttle exists to
202
+ * protect the daemon's automatic cadence, not to argue with the user. That
203
+ * default is deliberately unchanged — `--respect-throttle` opts back into it
204
+ * for scripted/scheduled callers, which are the only ones that should be
205
+ * bound by a cadence they did not personally ask to skip.
206
+ */
207
+ export declare function runSyncManifest(options: RunSyncManifestOptions, deps: SyncManifestDeps): Promise<RunSyncManifestOutcome>;
208
+ /**
209
+ * Wire `hq sync manifest` onto the existing `sync` Commander group, alongside
210
+ * `mode` and `narrow`.
211
+ */
212
+ export declare function registerSyncManifestCommand(syncCmd: Command): void;
213
+ //# sourceMappingURL=sync-manifest.d.ts.map
@@ -0,0 +1,384 @@
1
+ /**
2
+ * `hq sync manifest` (sync-reconciliation-audit US-004) — the CLI half of the
3
+ * shared client-side manifest upload pass.
4
+ *
5
+ * WHAT THIS FILE OWNS, AND WHAT IT DELIBERATELY DOES NOT
6
+ * -----------------------------------------------------
7
+ * All of the SAFETY rules for an upload pass (sequence monotonicity, when a
8
+ * snapshot may become a delta base, `resend_full` handling, chunk-header
9
+ * identity, the 24h throttle, the per-scope lock) live in hq-cloud's
10
+ * {@link runManifestUploadPass}. Duplicating any of them here would let the
11
+ * CLI and the hq-sync daemon drift, and the failure mode of that drift is
12
+ * silent server-side corruption of a scope's materialised view — the exact
13
+ * thing this audit exists to detect.
14
+ *
15
+ * This file therefore owns only the three things hq-cloud refuses to own:
16
+ *
17
+ * 1. **Auth + base URL.** The transport is injected; we build it from the
18
+ * CLI's own Cognito session and `vaultApiFetch`'s base-URL resolution.
19
+ * 2. **Scope resolution.** `personal` vs. a company SLUG → the `companyUid`
20
+ * the wire contract wants, resolved the same way `hq sync mode` does
21
+ * (memberships + `entity.get`), so both commands agree on what
22
+ * `--company frogbear` means.
23
+ * 3. **Human output + exit codes.** The seam never throws for a runtime
24
+ * condition; it returns a status. Only `failed` is a non-zero exit —
25
+ * `disabled`, `throttled`, `soft_skipped` and `locked` are all normal,
26
+ * expected outcomes of asking for a pass, and exiting non-zero on them
27
+ * would make the command unusable from a wrapper script.
28
+ *
29
+ * IDENTITY JOIN
30
+ * -------------
31
+ * `installationId` comes from {@link loadClientHealthState} — the SAME value
32
+ * the client-health heartbeat reports. Minting a second id here would produce
33
+ * manifest rows that cannot be joined to the heartbeat rows for the same
34
+ * machine, which is most of the value of the audit.
35
+ *
36
+ * Cross-package note: this command depends on `runManifestUploadPass` /
37
+ * `readManifestUploadStatus`, added in the hq-cloud sync-reconciliation-audit
38
+ * branch and NOT yet published. While that is true, package.json carries a
39
+ * `pnpm.overrides` link to the local hq-cloud worktree; that override must be
40
+ * removed and the `@indigoai-us/hq-cloud` pin bumped from `~6.16.6` to the
41
+ * release containing these exports before this branch can merge.
42
+ */
43
+ import * as fs from "node:fs";
44
+ import * as os from "node:os";
45
+ import * as path from "node:path";
46
+ import chalk from "chalk";
47
+ // `VaultClient` has shipped for many releases and is safe to import by name.
48
+ // The manifest exports are NOT: they are absent from the published hq-cloud,
49
+ // and a static named import of a missing ESM export is a link-time
50
+ // SyntaxError. They are feature-detected instead. Type-only imports are erased
51
+ // at runtime and so cost nothing here.
52
+ import { VaultClient } from "@indigoai-us/hq-cloud";
53
+ import { MANIFEST_MIN_HQ_CLOUD_VERSION, manifestExportsAvailable, requireManifestExports, } from "../lib/hq-cloud-manifest.js";
54
+ import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
55
+ import { loadClientHealthState, persistClientHealthState, } from "../utils/client-health.js";
56
+ import { vaultApiFetch } from "../utils/vault-api.js";
57
+ import { readActiveCompanySlug } from "./sync-mode.js";
58
+ // ── Constants ───────────────────────────────────────────────────────────────
59
+ /** The server route the pass POSTs each chunk to. */
60
+ export const MANIFEST_UPLOAD_PATH = "/v1/sync-manifest/upload";
61
+ /**
62
+ * The kill switch hq-cloud's pass honours. Restated locally (rather than
63
+ * imported) for two reasons: it lives in the same unreleased module as the
64
+ * manifest exports, and — more importantly — we must be able to read it
65
+ * BEFORE deciding to do any work at all.
66
+ */
67
+ export const MANIFEST_DISABLED_ENV = "HQ_SYNC_MANIFEST_DISABLED";
68
+ /** True when the kill switch is set to anything other than empty/0/false. */
69
+ export function manifestUploadsDisabled(env = process.env) {
70
+ const raw = env[MANIFEST_DISABLED_ENV];
71
+ if (raw === undefined)
72
+ return false;
73
+ const value = raw.trim().toLowerCase();
74
+ return value !== "" && value !== "0" && value !== "false";
75
+ }
76
+ /** The literal `--scope` value that selects the personal (non-company) tree. */
77
+ export const PERSONAL_SCOPE = "personal";
78
+ // ── Pure helpers ────────────────────────────────────────────────────────────
79
+ /**
80
+ * Resolve `--scope` into the wire scope.
81
+ *
82
+ * `personal` is the literal; anything else is a company SLUG, which must be
83
+ * turned into the server's `companyUid` — the contract carries the uid, while
84
+ * the local walk needs the slug (it names `companies/{slug}` and the journal
85
+ * shard), so BOTH end up on the resolved scope.
86
+ *
87
+ * With no `--scope`, we fall back to the active company recorded in
88
+ * `<hq-root>/.hq/config.json` (the same source `hq sync mode` uses), and to
89
+ * `personal` when there is no active company — a CLI-only install with no
90
+ * company still has a personal tree worth auditing.
91
+ */
92
+ export async function resolveManifestScope(scopeArg, hqRoot, resolveCompanyUid) {
93
+ const requested = scopeArg?.trim() || readActiveCompanySlug(hqRoot) || PERSONAL_SCOPE;
94
+ if (requested === PERSONAL_SCOPE)
95
+ return { kind: "personal" };
96
+ const companyUid = await resolveCompanyUid(requested);
97
+ return { kind: "company", companyUid, slug: requested };
98
+ }
99
+ /**
100
+ * A stable, human-meaningless machine id.
101
+ *
102
+ * Precedence mirrors the rest of the CLI's "ask the installer first" habit:
103
+ * the id HQ Sync recorded in `~/.hq/menubar.json` if there is one (so the
104
+ * daemon's manifests and the CLI's manifests report the SAME machine), then a
105
+ * sanitised hostname, then a constant. It is explicitly not a hardware
106
+ * fingerprint — the contract only needs it to be stable and opaque-ish.
107
+ */
108
+ export function resolveMachineId(hqConfigDir = path.join(os.homedir(), ".hq"),
109
+ // Injected rather than spied: `node:os` is an ESM namespace and cannot be
110
+ // redefined by `vi.spyOn`, so the fallback branches would be untestable.
111
+ hostname = os.hostname) {
112
+ const fromMenubar = readMenubarMachineId(hqConfigDir);
113
+ if (fromMenubar)
114
+ return fromMenubar;
115
+ const host = sanitiseIdentifier(hostname());
116
+ return host ?? "unknown-machine";
117
+ }
118
+ function readMenubarMachineId(hqConfigDir) {
119
+ try {
120
+ const parsed = JSON.parse(fs.readFileSync(path.join(hqConfigDir, "menubar.json"), "utf-8"));
121
+ const raw = parsed?.machineId;
122
+ return typeof raw === "string" ? sanitiseIdentifier(raw) : undefined;
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ }
128
+ /**
129
+ * Coerce to the contract's identifier charset (`[A-Za-z0-9][A-Za-z0-9_.:-]*`,
130
+ * 2..64). Returns undefined when nothing usable survives, so the caller can
131
+ * fall through rather than send a value the server will reject.
132
+ */
133
+ export function sanitiseIdentifier(raw) {
134
+ const cleaned = raw.replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 64);
135
+ if (!/^[A-Za-z0-9]/.test(cleaned) || cleaned.length < 2)
136
+ return undefined;
137
+ return cleaned;
138
+ }
139
+ /** Human label for a resolved scope — what the summary line names. */
140
+ export function describeScope(scope) {
141
+ return scope.kind === "personal"
142
+ ? PERSONAL_SCOPE
143
+ : `${scope.slug ?? "company"} (${scope.companyUid ?? "?"})`;
144
+ }
145
+ /**
146
+ * One-line-per-fact summary of a non-print pass.
147
+ *
148
+ * Every non-`failed` status gets an explanatory sentence rather than a bare
149
+ * enum: `throttled` and `soft_skipped` in particular are the statuses a user
150
+ * is most likely to see and most likely to misread as a failure.
151
+ */
152
+ export function formatManifestSummary(result, scope) {
153
+ const where = describeScope(scope);
154
+ switch (result.status) {
155
+ case "uploaded":
156
+ return `${chalk.green("✓")} Manifest uploaded for ${chalk.bold(where)} — ${result.mode ?? "?"} mode, ${result.uploadedChunks ?? 0}/${result.chunkCount ?? 0} chunk(s)${result.snapshotId ? `, snapshot ${result.snapshotId}` : ""}.`;
157
+ case "resend_full_scheduled":
158
+ return `${chalk.yellow("!")} Server asked for a fresh baseline for ${chalk.bold(where)} — the next pass will send a full manifest.`;
159
+ case "throttled":
160
+ return `${chalk.dim("·")} Skipped for ${chalk.bold(where)}: a manifest was uploaded recently (24h throttle). Nothing to do.`;
161
+ case "disabled":
162
+ return `${chalk.dim("·")} Manifest uploads are disabled on this machine (HQ_SYNC_MANIFEST_DISABLED).`;
163
+ case "soft_skipped":
164
+ return `${chalk.dim("·")} Skipped for ${chalk.bold(where)}: preconditions for a manifest pass are not met on this machine.`;
165
+ case "locked":
166
+ return `${chalk.dim("·")} Another manifest pass for ${chalk.bold(where)} is already running. Nothing to do.`;
167
+ case "printed":
168
+ return `${chalk.dim("·")} Built ${result.chunkCount ?? 0} chunk(s) for ${chalk.bold(where)} — nothing uploaded (--print).`;
169
+ case "failed":
170
+ default:
171
+ return `${chalk.red("✗")} Manifest pass failed for ${chalk.bold(where)}: ${result.error?.kind ?? "unknown"}${result.error?.status ? ` (HTTP ${result.error.status})` : ""}${result.error?.detail ? ` — ${result.error.detail}` : ""}`;
172
+ }
173
+ }
174
+ // ── Transport ───────────────────────────────────────────────────────────────
175
+ /**
176
+ * POST one chunk with the caller's Cognito bearer.
177
+ *
178
+ * It RESOLVES for every HTTP answer, including 4xx/5xx: the seam classifies
179
+ * status codes itself (a 200 can still carry a protocol-level `resend_full`,
180
+ * and a 409 means something quite specific), so throwing here would collapse
181
+ * all of that into an undifferentiated `transport_error`. A body that will not
182
+ * parse as JSON is passed through as `undefined` rather than failing the pass
183
+ * — the status is the load-bearing part.
184
+ */
185
+ export function createManifestTransport(token) {
186
+ return async (chunk) => {
187
+ const response = await vaultApiFetch({
188
+ token,
189
+ path: MANIFEST_UPLOAD_PATH,
190
+ method: "POST",
191
+ body: chunk,
192
+ });
193
+ let body;
194
+ try {
195
+ body = await response.json();
196
+ }
197
+ catch {
198
+ body = undefined;
199
+ }
200
+ return { status: response.status, body };
201
+ };
202
+ }
203
+ // ── Default dependencies ────────────────────────────────────────────────────
204
+ /**
205
+ * Read (and, when it had to be minted, persist) the installation id shared
206
+ * with the client-health heartbeat. Persisting matters: `loadClientHealthState`
207
+ * degrades a missing/corrupt file to a FRESH id, so a first run that did not
208
+ * write it back would report a different installation on every invocation and
209
+ * the manifest rows would never join to anything.
210
+ */
211
+ export function defaultInstallationId(stateDir) {
212
+ const state = loadClientHealthState(stateDir);
213
+ persistClientHealthState(stateDir, state);
214
+ return state.installationId;
215
+ }
216
+ /** Max concurrent `entity.get` calls when resolving a slug to a companyUid. */
217
+ export const MEMBERSHIP_RESOLVE_CONCURRENCY = 4;
218
+ /**
219
+ * `Promise.all`-shaped map with a hard ceiling on in-flight work. Results keep
220
+ * input order, so callers can still index by position.
221
+ */
222
+ export async function mapWithConcurrency(items, limit, worker) {
223
+ const results = new Array(items.length);
224
+ let next = 0;
225
+ const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
226
+ for (;;) {
227
+ const index = next++;
228
+ if (index >= items.length)
229
+ return;
230
+ results[index] = await worker(items[index], index);
231
+ }
232
+ });
233
+ await Promise.all(runners);
234
+ return results;
235
+ }
236
+ function defaultResolveCompanyUid(client) {
237
+ return async (slug) => {
238
+ const vault = await client();
239
+ const memberships = await vault.listMyMemberships();
240
+ // Bounded fan-out: a `Promise.all` over every membership fires one
241
+ // `entity.get` per company at once, which on a heavily-membered account is
242
+ // a self-inflicted burst against the vault API (and the first thing to get
243
+ // rate-limited). Four in flight keeps it brisk without stampeding.
244
+ const resolved = await mapWithConcurrency(memberships, MEMBERSHIP_RESOLVE_CONCURRENCY, async (m) => {
245
+ try {
246
+ const entity = await vault.entity.get(m.companyUid);
247
+ return { uid: m.companyUid, slug: entity.slug };
248
+ }
249
+ catch {
250
+ return { uid: m.companyUid, slug: undefined };
251
+ }
252
+ });
253
+ const match = resolved.find((row) => row.slug === slug);
254
+ if (!match) {
255
+ const known = resolved
256
+ .map((r) => r.slug)
257
+ .filter((s) => !!s)
258
+ .join(", ");
259
+ throw new Error(`No membership found for company '${slug}'. Memberships visible to you: ${known || "(none)"}.`);
260
+ }
261
+ return match.uid;
262
+ };
263
+ }
264
+ /**
265
+ * Production wiring. Built lazily per invocation because both the vault client
266
+ * and the transport need a Cognito token, and `--print` must work without one
267
+ * (it uploads nothing, so demanding a login would be gratuitous).
268
+ */
269
+ export function createDefaultDeps() {
270
+ let cachedToken;
271
+ const token = () => (cachedToken ??= ensureCognitoToken());
272
+ const vaultClient = async () => new VaultClient(buildVaultConfig(await token()));
273
+ // Throws ManifestUnsupportedError on an hq-cloud that predates the manifest
274
+ // exports. The CLI action checks availability first and prints a clean
275
+ // requirement message, so this is the belt-and-braces path.
276
+ const manifest = requireManifestExports();
277
+ return {
278
+ runPass: (options) => manifest.runManifestUploadPass(options),
279
+ resolveCompanyUid: defaultResolveCompanyUid(vaultClient),
280
+ stateDir: () => manifest.getStateDir(),
281
+ installationId: defaultInstallationId,
282
+ machineId: () => resolveMachineId(),
283
+ transport: async () => createManifestTransport(await token()),
284
+ log: (line) => console.log(line),
285
+ };
286
+ }
287
+ // ── Orchestrator ────────────────────────────────────────────────────────────
288
+ /**
289
+ * Resolve the scope, run one upload pass, print, and decide the exit code.
290
+ *
291
+ * `--print` short-circuits the transport entirely (it is never built, so no
292
+ * token is required) and hands the seam `dryRun: true`, which builds the
293
+ * chunks without persisting a snapshot. The chunks go to stdout as JSON and
294
+ * nothing else is printed, so the output is pipeable into `jq`.
295
+ *
296
+ * An explicit invocation passes `ignoreThrottle: true`: a human typing
297
+ * `hq sync manifest` has asked for a pass NOW, and the 24h throttle exists to
298
+ * protect the daemon's automatic cadence, not to argue with the user. That
299
+ * default is deliberately unchanged — `--respect-throttle` opts back into it
300
+ * for scripted/scheduled callers, which are the only ones that should be
301
+ * bound by a cadence they did not personally ask to skip.
302
+ */
303
+ export async function runSyncManifest(options, deps) {
304
+ // KILL SWITCH FIRST — before scope resolution, before the state dir, and
305
+ // above all before `deps.transport()`, which mints a Cognito session and can
306
+ // block for minutes on a machine with no usable credentials. The seam also
307
+ // honours this env var, but only after we have already paid for auth, so
308
+ // "disabled" used to mean "hang, then do nothing". Disabled is a normal,
309
+ // expected outcome: exit 0.
310
+ if (manifestUploadsDisabled()) {
311
+ const result = {
312
+ status: "disabled",
313
+ scopeKey: "unknown",
314
+ };
315
+ deps.log(`${chalk.dim("·")} Manifest upload disabled by ${MANIFEST_DISABLED_ENV} — nothing to do.`);
316
+ return { result, scope: { kind: "personal" }, exitCode: 0 };
317
+ }
318
+ const scope = await resolveManifestScope(options.scopeArg, options.hqRoot, deps.resolveCompanyUid);
319
+ const stateDir = deps.stateDir();
320
+ const print = options.print === true;
321
+ const result = await deps.runPass({
322
+ scope,
323
+ hqRoot: options.hqRoot,
324
+ stateDir,
325
+ installationId: deps.installationId(stateDir),
326
+ machineId: deps.machineId(),
327
+ source: "cli",
328
+ // `--print` uploads nothing; a no-op transport keeps us from demanding a
329
+ // Cognito session for a purely local build.
330
+ transport: print
331
+ ? () => Promise.resolve({ status: 0 })
332
+ : await deps.transport(),
333
+ dryRun: print,
334
+ forceFull: options.full === true,
335
+ ignoreThrottle: options.respectThrottle !== true,
336
+ });
337
+ if (print) {
338
+ deps.log(JSON.stringify(result.chunks ?? [], null, 2));
339
+ }
340
+ else {
341
+ deps.log(formatManifestSummary(result, scope));
342
+ }
343
+ return { result, scope, exitCode: result.status === "failed" ? 1 : 0 };
344
+ }
345
+ /**
346
+ * Wire `hq sync manifest` onto the existing `sync` Commander group, alongside
347
+ * `mode` and `narrow`.
348
+ */
349
+ export function registerSyncManifestCommand(syncCmd) {
350
+ syncCmd
351
+ .command("manifest")
352
+ .description("Upload a file manifest for the sync reconciliation audit (personal or a company scope)")
353
+ .option("--scope <scope>", `'${PERSONAL_SCOPE}' or a company slug (defaults to the active company, else ${PERSONAL_SCOPE})`)
354
+ .option("--full", "Send a fresh full baseline instead of a delta")
355
+ .option("--print", "Build and print the chunks as JSON; upload nothing")
356
+ .option("--respect-throttle", "Honour the 24h per-scope upload throttle instead of forcing a pass (for scheduled callers)")
357
+ .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
358
+ .action(async (options) => {
359
+ // Capability gate BEFORE anything else: on a published hq-cloud that
360
+ // predates the manifest exports this command cannot work, and the honest
361
+ // answer is a one-line requirement, not a stack trace.
362
+ if (!manifestExportsAvailable()) {
363
+ console.error(chalk.red("✗ sync manifest is unavailable:"), `this command requires an hq-cloud release with manifest support (>= ${MANIFEST_MIN_HQ_CLOUD_VERSION}); the installed @indigoai-us/hq-cloud does not export the manifest upload pass.`);
364
+ process.exit(1);
365
+ return;
366
+ }
367
+ try {
368
+ const outcome = await runSyncManifest({
369
+ scopeArg: options.scope,
370
+ hqRoot: options.hqRoot,
371
+ full: options.full,
372
+ print: options.print,
373
+ respectThrottle: options.respectThrottle,
374
+ }, createDefaultDeps());
375
+ if (outcome.exitCode !== 0)
376
+ process.exit(outcome.exitCode);
377
+ }
378
+ catch (err) {
379
+ console.error(chalk.red("✗ sync manifest failed:"), err instanceof Error ? err.message : String(err));
380
+ process.exit(1);
381
+ }
382
+ });
383
+ }
384
+ //# sourceMappingURL=sync-manifest.js.map
@@ -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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.18",
3
+ "version": "5.108.19",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
33
33
  "@aws-sdk/client-s3": "^3.1049.0",
34
- "@indigoai-us/hq-cloud": "~6.16.11",
34
+ "@indigoai-us/hq-cloud": "~6.16.23",
35
35
  "@indigoai-us/hq-flags-client": "^0.1.2",
36
36
  "@indigoai-us/hq-onboarding": "^0.1.0",
37
37
  "@sentry/node": "^10.49.0",