@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.
- package/CHANGELOG.md +38 -18
- package/dist/commands/sync-manifest.d.ts +213 -0
- package/dist/commands/sync-manifest.js +384 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +46 -1
- package/dist/lib/doctor/checks/sync-health.js +212 -5
- package/dist/lib/hq-cloud-manifest.d.ts +62 -0
- package/dist/lib/hq-cloud-manifest.js +83 -0
- package/dist/main.js +51 -7
- package/dist/register-all.js +2 -0
- package/dist/startup-registration.d.ts +79 -0
- package/dist/startup-registration.js +134 -0
- package/dist/utils/install-tree-torn.d.ts +204 -0
- package/dist/utils/install-tree-torn.js +359 -0
- package/dist/utils/sentry-fingerprint.js +26 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.19] — 2026-09-07
|
|
6
|
+
|
|
7
|
+
## [5.108.18] — 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- A global `hq` reinstall running underneath a starting command no longer
|
|
12
|
+
crashes it (Sentry HQ-CLI-1G/1H/1J/1K — 7713386011, 7713482655, 7713783135,
|
|
13
|
+
7714501738). `hq` resolves most of its command graph lazily as its first real
|
|
14
|
+
step, so when an external updater — the outpost `hq-cli-update` timer, an
|
|
15
|
+
agent box's self-update, or the desktop app — renames the installed package
|
|
16
|
+
aside and re-extracts it file by file (~16s every 6h on every box), a command
|
|
17
|
+
that started a moment earlier resolved a deferred import against a directory
|
|
18
|
+
that no longer held it and died with `ERR_MODULE_NOT_FOUND` /
|
|
19
|
+
`MODULE_NOT_FOUND`, filing a separate high-priority crash for each missing
|
|
20
|
+
path. hq-cli cannot stop the foreign updater, but the registration phase is
|
|
21
|
+
provably side-effect free (it runs before any command action), so a
|
|
22
|
+
resolution failure there is now caught: the CLI prints one informational line,
|
|
23
|
+
waits — bounded, default 90s, `HQ_INSTALL_SETTLE_TIMEOUT_MS` to override — for
|
|
24
|
+
the install to settle (shared update lock released, the missing target back,
|
|
25
|
+
the retired `.hq-cli-*` sibling gone, a healthy manifest), and then re-runs
|
|
26
|
+
the command once on the settled tree. A genuinely broken or incomplete install
|
|
27
|
+
still reports exactly once, with a reinstall remedy naming the missing module
|
|
28
|
+
and bounded diagnostics, so real packaging faults stay visible. The recovery
|
|
29
|
+
re-execs at most once per invocation and never runs a command twice.
|
|
30
|
+
|
|
5
31
|
## [5.108.17] — 2026-09-07
|
|
6
32
|
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- The Work Mesh Live daemon's transcript watcher no longer rescans every file
|
|
36
|
+
under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
|
|
37
|
+
per-directory mtime cache and only relists directories that changed, re-stats
|
|
38
|
+
recently active files, revalidates every cached directory at least every five
|
|
39
|
+
minutes (content appends do not change a directory's mtime, so a session that
|
|
40
|
+
resumes after idling is still picked up), backs the interval off from 30 s to
|
|
41
|
+
120 s on busy machines, caps the entries examined per tick with per-root
|
|
42
|
+
budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
|
|
43
|
+
disappearance reconciliation so an unvisited session is never ended falsely.
|
|
44
|
+
|
|
7
45
|
## [5.108.16] — 2026-09-07
|
|
8
46
|
|
|
9
47
|
### Fixed
|
|
@@ -41,15 +79,6 @@
|
|
|
41
79
|
at most every five minutes, and `held.jsonl` is capped at 20,000 lines with
|
|
42
80
|
the oldest overflow dead-lettered as `HELD_OVERFLOW` (loss-free under failure
|
|
43
81
|
and concurrent flushers). (#525)
|
|
44
|
-
- The Work Mesh Live daemon's transcript watcher no longer rescans every file
|
|
45
|
-
under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
|
|
46
|
-
per-directory mtime cache and only relists directories that changed, re-stats
|
|
47
|
-
recently active files, revalidates every cached directory at least every five
|
|
48
|
-
minutes (content appends do not change a directory's mtime, so a session that
|
|
49
|
-
resumes after idling is still picked up), backs the interval off from 30 s to
|
|
50
|
-
120 s on busy machines, caps the entries examined per tick with per-root
|
|
51
|
-
budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
|
|
52
|
-
disappearance reconciliation so an unvisited session is never ended falsely.
|
|
53
82
|
|
|
54
83
|
## [5.108.14] — 2026-09-07
|
|
55
84
|
|
|
@@ -81,15 +110,6 @@
|
|
|
81
110
|
`incomplete_install` context so the next occurrence is attributable. The drop
|
|
82
111
|
is wired both at the top-level boundary and in the shared `beforeSend`, so it
|
|
83
112
|
covers every capture route.
|
|
84
|
-
- The Work Mesh Live daemon's transcript watcher no longer rescans every file
|
|
85
|
-
under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
|
|
86
|
-
per-directory mtime cache and only relists directories that changed, re-stats
|
|
87
|
-
recently active files, revalidates every cached directory at least every five
|
|
88
|
-
minutes (content appends do not change a directory's mtime, so a session that
|
|
89
|
-
resumes after idling is still picked up), backs the interval off from 30 s to
|
|
90
|
-
120 s on busy machines, caps the entries examined per tick with per-root
|
|
91
|
-
budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
|
|
92
|
-
disappearance reconciliation so an unvisited session is never ended falsely.
|
|
93
113
|
|
|
94
114
|
## [5.108.13] — 2026-09-06
|
|
95
115
|
|
|
@@ -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
|