@indigoai-us/hq-cli 5.85.1 → 5.85.3
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 +26 -0
- package/dist/commands/cloud.js +51 -14
- package/dist/commands/core-checkpoint.js +15 -2
- package/dist/commands/sync-mode.js +5 -1
- package/dist/lib/narrow-hint-banner.d.ts +76 -10
- package/dist/lib/narrow-hint-banner.js +162 -19
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.85.3]
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- The shared-mode sync nudge is now size-gated: it appears only once a
|
|
10
|
+
company's local folder crosses ~5 GiB (overridable via
|
|
11
|
+
`HQ_SYNC_NARROW_HINT_MIN_BYTES` or `.hq/config.json`
|
|
12
|
+
`syncNarrowHintMinBytes`), instead of nudging every all-mode member toward
|
|
13
|
+
shared mode. Below the threshold an all-mode membership is left alone — no
|
|
14
|
+
banner, and in strict mode no refusal. `hq sync mode` now also reports `all`
|
|
15
|
+
(the effective default) rather than `shared` when a config fetch fails.
|
|
16
|
+
(#303)
|
|
17
|
+
|
|
18
|
+
## [5.85.2]
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Bumped the `@indigoai-us/hq-cloud` floor to ^6.14.45, which scopes the sync
|
|
23
|
+
watcher to paths that can actually upload. On a large HQ root the watcher had
|
|
24
|
+
been asking the OS to report every directory in the tree — including
|
|
25
|
+
`repos/`, `workspace/worktrees/`, `node_modules/` and build output that sync
|
|
26
|
+
never uploads — and paying a blocking stat per event before discarding it.
|
|
27
|
+
One observed root reported 154,489 directories where 20,308 were in scope,
|
|
28
|
+
with the sync runner pinned above 100% CPU in garbage collection and growing
|
|
29
|
+
to 1.2 GB RSS over a 12-hour run. (hq-cloud#283)
|
|
30
|
+
|
|
5
31
|
## [5.85.1]
|
|
6
32
|
|
|
7
33
|
### Fixed
|
package/dist/commands/cloud.js
CHANGED
|
@@ -17,7 +17,7 @@ import * as fs from "fs";
|
|
|
17
17
|
import * as path from "path";
|
|
18
18
|
import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, } from "@indigoai-us/hq-cloud";
|
|
19
19
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
20
|
-
import { emitNarrowHint, isStrictRefusal, resolveBannerLevel, } from "../lib/narrow-hint-banner.js";
|
|
20
|
+
import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
|
|
21
21
|
/**
|
|
22
22
|
* Build a loud, human-readable warning when a push dropped files because
|
|
23
23
|
* they fell outside the caller's granted write scope. Returns null when
|
|
@@ -116,6 +116,26 @@ function readScopeExcludePrefixes(scope) {
|
|
|
116
116
|
const prefixes = raw.filter((p) => typeof p === "string" && p.length > 0);
|
|
117
117
|
return prefixes.length > 0 ? prefixes : undefined;
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* The size gate for the narrow-mode nudge. `all` is the default sync mode, so
|
|
121
|
+
* an all-mode membership is nudged toward shared mode ONLY once its local
|
|
122
|
+
* `companies/<slug>/` folder crosses the configured byte threshold (default
|
|
123
|
+
* 5 GiB). Below that, all-mode is left alone — no banner, and (in strict mode)
|
|
124
|
+
* no refusal.
|
|
125
|
+
*
|
|
126
|
+
* `ref` is the caller's company selector, which may be a slug OR a `cmp_*` /
|
|
127
|
+
* `prs_*` uid. The on-disk folder is keyed by SLUG, so a uid selector can't be
|
|
128
|
+
* measured — it degrades to `false` (no nudge), which is the correct
|
|
129
|
+
* best-effort: the nudge is a convenience, never load-bearing. A folder that
|
|
130
|
+
* has never synced (missing directory) likewise measures under threshold.
|
|
131
|
+
*/
|
|
132
|
+
function narrowNudgeExceedsSize(hqRoot, ref) {
|
|
133
|
+
if (!ref || ref.startsWith("cmp_") || ref.startsWith("prs_"))
|
|
134
|
+
return false;
|
|
135
|
+
const companyDir = path.join(hqRoot, "companies", ref);
|
|
136
|
+
const threshold = resolveNarrowHintMinBytes({ hqRoot });
|
|
137
|
+
return companyFolderExceedsThreshold(companyDir, threshold);
|
|
138
|
+
}
|
|
119
139
|
export async function pullAll(options, deps) {
|
|
120
140
|
const memberships = await deps.vaultClient.listMyMemberships();
|
|
121
141
|
const persons = await deps.vaultClient.listPersonEntities();
|
|
@@ -212,7 +232,13 @@ export async function pullAll(options, deps) {
|
|
|
212
232
|
if (options.forceScopeShrink && entry.companyUid) {
|
|
213
233
|
entry.syncOptions.forceScopeShrink = true;
|
|
214
234
|
}
|
|
235
|
+
// Size gate: an all-mode membership is only nudged / strict-refused once
|
|
236
|
+
// its local folder has grown past the threshold. Computed once per leg.
|
|
237
|
+
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
238
|
+
entry.companyUid !== undefined &&
|
|
239
|
+
narrowNudgeExceedsSize(options.hqRoot, entry.slug);
|
|
215
240
|
if (resolvedMode === "all" &&
|
|
241
|
+
nudgeExceedsSize &&
|
|
216
242
|
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
217
243
|
!options.modeAllOverride &&
|
|
218
244
|
entry.companyUid) {
|
|
@@ -239,8 +265,8 @@ export async function pullAll(options, deps) {
|
|
|
239
265
|
result.perCompany.push({ slug: entry.slug, result: r });
|
|
240
266
|
// Banner emitted AFTER the leg succeeds so it appears alongside
|
|
241
267
|
// the per-company summary line and doesn't get scrolled off by
|
|
242
|
-
// sync chatter.
|
|
243
|
-
if (resolvedMode === "all" && entry.companyUid) {
|
|
268
|
+
// sync chatter. Size-gated: only a large local folder is nudged.
|
|
269
|
+
if (resolvedMode === "all" && nudgeExceedsSize && entry.companyUid) {
|
|
244
270
|
emitNarrowHint({
|
|
245
271
|
companyUid: entry.companyUid,
|
|
246
272
|
syncMode: resolvedMode,
|
|
@@ -806,11 +832,16 @@ export function registerCloudCommands(program) {
|
|
|
806
832
|
// thread it into the pull below — not just the banner. Best-effort;
|
|
807
833
|
// degrades to "all" inside the resolver on any failure.
|
|
808
834
|
const pullScope = await resolveCliPullScope(pullClient, options.company, options.hqRoot);
|
|
835
|
+
// Size gate: only a large local folder is nudged / strict-refused.
|
|
836
|
+
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
837
|
+
resolvedCompanyUid !== undefined &&
|
|
838
|
+
narrowNudgeExceedsSize(options.hqRoot, options.company);
|
|
809
839
|
// Strict-mode refusal: matches runPullAll + runNowSingle behavior.
|
|
810
840
|
// Default banner level is 'hint' which never triggers refusal —
|
|
811
|
-
// wired now so future
|
|
812
|
-
//
|
|
841
|
+
// wired now so a future release can flip the default to 'strict'
|
|
842
|
+
// (still size-gated) without re-touching this command.
|
|
813
843
|
if (resolvedMode === "all" &&
|
|
844
|
+
nudgeExceedsSize &&
|
|
814
845
|
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
815
846
|
options.modeAll !== true &&
|
|
816
847
|
resolvedCompanyUid) {
|
|
@@ -820,7 +851,7 @@ export function registerCloudCommands(program) {
|
|
|
820
851
|
level: narrowHintLevel,
|
|
821
852
|
});
|
|
822
853
|
console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
|
|
823
|
-
"
|
|
854
|
+
"company's local folder has grown large. Run `hq sync narrow --apply` " +
|
|
824
855
|
"to migrate, or re-run with --mode-all."));
|
|
825
856
|
process.exit(1);
|
|
826
857
|
}
|
|
@@ -846,10 +877,10 @@ export function registerCloudCommands(program) {
|
|
|
846
877
|
process.exit(1);
|
|
847
878
|
}
|
|
848
879
|
console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
if (resolvedMode === "all" && resolvedCompanyUid) {
|
|
880
|
+
// Emit the hint banner after success so it appears alongside the
|
|
881
|
+
// summary line. Mirrors the wiring in runPullAll and runNowSingle.
|
|
882
|
+
// Size-gated: only a large local folder is nudged.
|
|
883
|
+
if (resolvedMode === "all" && nudgeExceedsSize && resolvedCompanyUid) {
|
|
853
884
|
emitNarrowHint({
|
|
854
885
|
companyUid: resolvedCompanyUid,
|
|
855
886
|
syncMode: resolvedMode,
|
|
@@ -1304,7 +1335,12 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1304
1335
|
resolvedMode = undefined;
|
|
1305
1336
|
}
|
|
1306
1337
|
}
|
|
1338
|
+
// Size gate: only a large local folder is nudged / strict-refused.
|
|
1339
|
+
const nudgeExceedsSize = resolvedMode === "all" &&
|
|
1340
|
+
resolvedCompanyUid !== undefined &&
|
|
1341
|
+
narrowNudgeExceedsSize(hqRoot, targetCompany);
|
|
1307
1342
|
if (resolvedMode === "all" &&
|
|
1343
|
+
nudgeExceedsSize &&
|
|
1308
1344
|
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
1309
1345
|
!modeAllOverride &&
|
|
1310
1346
|
resolvedCompanyUid) {
|
|
@@ -1314,7 +1350,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1314
1350
|
level: narrowHintLevel,
|
|
1315
1351
|
});
|
|
1316
1352
|
console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
|
|
1317
|
-
"
|
|
1353
|
+
"company's local folder has grown large. Run `hq sync narrow --apply` " +
|
|
1318
1354
|
"to migrate, or re-run with --mode-all."));
|
|
1319
1355
|
process.exit(1);
|
|
1320
1356
|
}
|
|
@@ -1358,9 +1394,10 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1358
1394
|
console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
|
|
1359
1395
|
process.exit(1);
|
|
1360
1396
|
}
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1397
|
+
// Emit the hint banner after a successful pull so it appears at the
|
|
1398
|
+
// bottom of the summary rather than mid-stream. Size-gated: only a large
|
|
1399
|
+
// local folder is nudged.
|
|
1400
|
+
if (resolvedMode === "all" && nudgeExceedsSize && resolvedCompanyUid) {
|
|
1364
1401
|
emitNarrowHint({
|
|
1365
1402
|
companyUid: resolvedCompanyUid,
|
|
1366
1403
|
syncMode: resolvedMode,
|
|
@@ -307,7 +307,20 @@ function writeStamps(liveRoot, sessionId) {
|
|
|
307
307
|
fs.writeFileSync(stampPath, timestamp);
|
|
308
308
|
return stampPaths;
|
|
309
309
|
}
|
|
310
|
-
|
|
310
|
+
/**
|
|
311
|
+
* Codex Stop-gate rollout scope: every HQ user on the operator domain, rather
|
|
312
|
+
* than the single account that dogfooded it first. Eligibility is decided on
|
|
313
|
+
* the email domain alone — a candidate must have exactly one `@` and the part
|
|
314
|
+
* after it must equal this domain, so lookalikes never pass (`xgetindigo.ai`,
|
|
315
|
+
* `getindigo.ai.evil.test`, `a@b@getindigo.ai`).
|
|
316
|
+
*/
|
|
317
|
+
const CODEX_CHECKPOINT_DOMAIN = "getindigo.ai";
|
|
318
|
+
function hasCodexCheckpointDomain(candidate) {
|
|
319
|
+
if (typeof candidate !== "string")
|
|
320
|
+
return false;
|
|
321
|
+
const parts = candidate.trim().toLowerCase().split("@");
|
|
322
|
+
return parts.length === 2 && parts[0].length > 0 && parts[1] === CODEX_CHECKPOINT_DOMAIN;
|
|
323
|
+
}
|
|
311
324
|
function checkpointGateRuntime() {
|
|
312
325
|
const runtime = (process.env.HQ_CHECKPOINT_RUNTIME ?? "claude").trim().toLowerCase();
|
|
313
326
|
if (runtime === "claude" || runtime === "codex")
|
|
@@ -331,7 +344,7 @@ function gateEligibility(runtime) {
|
|
|
331
344
|
return false;
|
|
332
345
|
const claims = peekIdToken(tokens.idToken);
|
|
333
346
|
const candidateEmails = [claims.email, claims["custom:delegatedEmail"]];
|
|
334
|
-
return candidateEmails.some(
|
|
347
|
+
return candidateEmails.some(hasCodexCheckpointDomain);
|
|
335
348
|
}
|
|
336
349
|
catch {
|
|
337
350
|
return false;
|
|
@@ -139,7 +139,11 @@ export async function showSyncModes(options) {
|
|
|
139
139
|
const [config, entity] = await Promise.all([
|
|
140
140
|
vaultClient.getMembershipSyncConfig(m.membershipKey).catch(() => ({
|
|
141
141
|
membershipId: m.membershipKey,
|
|
142
|
-
|
|
142
|
+
// Display fallback when the config fetch fails: show 'all', the
|
|
143
|
+
// effective default a membership resolves to (see
|
|
144
|
+
// DEFAULT_MEMBERSHIP_SYNC_MODE / resolveEffectiveSyncMode). Showing
|
|
145
|
+
// 'shared' here would misreport the default the user is actually on.
|
|
146
|
+
syncMode: "all",
|
|
143
147
|
isDefault: true,
|
|
144
148
|
})),
|
|
145
149
|
vaultClient.entity
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `narrow-hint-banner`
|
|
3
|
-
*
|
|
2
|
+
* `narrow-hint-banner` — one-time-per-session hint suggesting that a member
|
|
3
|
+
* whose LOCAL company folder has grown large could switch to shared-mode sync
|
|
4
|
+
* to pull fewer files.
|
|
5
|
+
*
|
|
6
|
+
* `all` is the DEFAULT sync mode (see `DEFAULT_MEMBERSHIP_SYNC_MODE` in
|
|
7
|
+
* hq-pro): a member who can see a company gets that company's files. Shared
|
|
8
|
+
* mode is a deliberate, opt-in NARROWING — worth suggesting only once the
|
|
9
|
+
* folder is big enough that pulling all of it actually costs disk/bandwidth.
|
|
10
|
+
* So the nudge is SIZE-GATED: below the threshold (default 5 GiB, see
|
|
11
|
+
* `DEFAULT_NARROW_HINT_MIN_BYTES`) no banner is ever shown, and an all-mode
|
|
12
|
+
* membership with a small folder is left completely alone.
|
|
4
13
|
*
|
|
5
14
|
* Emitted from `hq sync pull --all` and `hq sync now` after the per-target
|
|
6
15
|
* fanout resolves each membership's sync config. Suppressed when:
|
|
7
16
|
*
|
|
8
17
|
* - the membership is NOT on `syncMode: 'all'` (shared / custom users
|
|
9
18
|
* have already opted in to narrowing, so there is nothing to nudge),
|
|
19
|
+
* - the local `companies/<slug>/` folder is under the size threshold
|
|
20
|
+
* (the primary gate — the call site computes this and only emits when
|
|
21
|
+
* it is exceeded),
|
|
10
22
|
* - the env var `HQ_SYNC_NARROW_HINT=off` is set,
|
|
11
23
|
* - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
|
|
12
24
|
* `syncNarrowHint: 'off'`,
|
|
@@ -16,9 +28,9 @@
|
|
|
16
28
|
* banner per company per level).
|
|
17
29
|
*
|
|
18
30
|
* Three escalating levels — `'hint' | 'warning' | 'strict'`. Today's
|
|
19
|
-
* default level is `'hint'`;
|
|
20
|
-
*
|
|
21
|
-
*
|
|
31
|
+
* default level is `'hint'`; the plumbing lets an install escalate a large
|
|
32
|
+
* folder toward shared mode without re-touching the call sites. The size
|
|
33
|
+
* gate applies to every level: strict never refuses a small folder.
|
|
22
34
|
*
|
|
23
35
|
* - hint → dim suggestion to stderr, never blocks.
|
|
24
36
|
* - warning → yellow note to stderr, never blocks.
|
|
@@ -29,13 +41,18 @@
|
|
|
29
41
|
*
|
|
30
42
|
* The level is selected by the caller (typically from the
|
|
31
43
|
* `HQ_SYNC_NARROW_HINT_LEVEL` env var). See `resolveBannerLevel` for the
|
|
32
|
-
* default-and-override ladder.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* TODO(hq-core-staging release N+3): bump default level to 'strict' and
|
|
36
|
-
* wire `--mode-all` as the only opt-out.
|
|
44
|
+
* default-and-override ladder. The size threshold is overridable per install
|
|
45
|
+
* via `HQ_SYNC_NARROW_HINT_MIN_BYTES` or `.hq/config.json`
|
|
46
|
+
* `syncNarrowHintMinBytes` — see `resolveNarrowHintMinBytes`.
|
|
37
47
|
*/
|
|
48
|
+
import * as fs from "node:fs";
|
|
38
49
|
export type BannerLevel = "hint" | "warning" | "strict";
|
|
50
|
+
/**
|
|
51
|
+
* Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
|
|
52
|
+
* smaller than this is cheap to keep in full, so all-mode is left alone and no
|
|
53
|
+
* banner is shown. Overridable per install — see `resolveNarrowHintMinBytes`.
|
|
54
|
+
*/
|
|
55
|
+
export declare const DEFAULT_NARROW_HINT_MIN_BYTES: number;
|
|
39
56
|
export interface BannerInput {
|
|
40
57
|
/** Company UID — used to dedupe per-process so each company emits once. */
|
|
41
58
|
companyUid: string;
|
|
@@ -43,6 +60,12 @@ export interface BannerInput {
|
|
|
43
60
|
syncMode: "shared" | "all" | "custom";
|
|
44
61
|
/** Escalation level — see file header. */
|
|
45
62
|
level: BannerLevel;
|
|
63
|
+
/**
|
|
64
|
+
* Measured size of the local `companies/<slug>/` folder, in bytes. Optional
|
|
65
|
+
* and cosmetic: when present it is rendered into the message ("~6.2 GB") so
|
|
66
|
+
* the operator sees WHY the nudge fired. Absent → the message omits the size.
|
|
67
|
+
*/
|
|
68
|
+
folderBytes?: number;
|
|
46
69
|
}
|
|
47
70
|
export interface ShouldShowBannerOpts {
|
|
48
71
|
/**
|
|
@@ -78,11 +101,54 @@ export declare function shouldShowBanner(opts?: ShouldShowBannerOpts): boolean;
|
|
|
78
101
|
* facing and a typo shouldn't break a sync.
|
|
79
102
|
*/
|
|
80
103
|
export declare function resolveBannerLevel(envValue?: string | undefined): BannerLevel;
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the size threshold (in bytes) above which the narrow nudge fires.
|
|
106
|
+
* Precedence, first match wins:
|
|
107
|
+
*
|
|
108
|
+
* 1. `HQ_SYNC_NARROW_HINT_MIN_BYTES` env var (integer bytes),
|
|
109
|
+
* 2. `<hqRoot>/.hq/config.json` → `syncNarrowHintMinBytes` (integer bytes),
|
|
110
|
+
* 3. `DEFAULT_NARROW_HINT_MIN_BYTES` (5 GiB).
|
|
111
|
+
*
|
|
112
|
+
* A non-integer, negative, or otherwise unparseable override is ignored (falls
|
|
113
|
+
* through to the next source) rather than throwing — this is operator-facing
|
|
114
|
+
* config and a typo must not break a sync.
|
|
115
|
+
*/
|
|
116
|
+
export declare function resolveNarrowHintMinBytes(opts?: {
|
|
117
|
+
hqRoot?: string;
|
|
118
|
+
/** Test seam — defaults to `process.env.HQ_SYNC_NARROW_HINT_MIN_BYTES`. */
|
|
119
|
+
envValue?: string | undefined;
|
|
120
|
+
readFile?: (p: string) => string;
|
|
121
|
+
existsFile?: (p: string) => boolean;
|
|
122
|
+
}): number;
|
|
123
|
+
/**
|
|
124
|
+
* Does the on-disk `companies/<slug>/` folder meet or exceed `thresholdBytes`?
|
|
125
|
+
*
|
|
126
|
+
* Walks the tree summing regular-file sizes and SHORT-CIRCUITS the instant the
|
|
127
|
+
* running total reaches the threshold, so a huge folder costs only enough
|
|
128
|
+
* `stat`s to cross the line rather than a full enumeration. Symlinks are
|
|
129
|
+
* counted by their own (link) size and never followed, so a symlink cycle
|
|
130
|
+
* cannot wedge the walk.
|
|
131
|
+
*
|
|
132
|
+
* Best-effort: a missing folder (never synced yet), a permission error, or any
|
|
133
|
+
* other I/O fault resolves to `false`. Not being able to prove a folder is
|
|
134
|
+
* large means we do NOT nag — the nudge is a convenience, never a blocker.
|
|
135
|
+
*/
|
|
136
|
+
export declare function companyFolderExceedsThreshold(companyDir: string, thresholdBytes: number, deps?: {
|
|
137
|
+
readdir?: (p: string) => fs.Dirent[];
|
|
138
|
+
lstat?: (p: string) => {
|
|
139
|
+
size: number;
|
|
140
|
+
};
|
|
141
|
+
}): boolean;
|
|
81
142
|
/**
|
|
82
143
|
* Returns `true` when the strict-mode rollout has been opted into AND
|
|
83
144
|
* the membership in question is still on `'all'`. Call sites should
|
|
84
145
|
* refuse to proceed (exit non-zero) when this returns true and the
|
|
85
146
|
* operator hasn't passed `--mode-all`.
|
|
147
|
+
*
|
|
148
|
+
* NOTE: this does NOT encode the size gate — the size gate is a separate,
|
|
149
|
+
* mandatory precondition the call site checks FIRST (see
|
|
150
|
+
* `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
|
|
151
|
+
* folder is under the threshold is never refused.
|
|
86
152
|
*/
|
|
87
153
|
export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel): boolean;
|
|
88
154
|
/**
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `narrow-hint-banner`
|
|
3
|
-
*
|
|
2
|
+
* `narrow-hint-banner` — one-time-per-session hint suggesting that a member
|
|
3
|
+
* whose LOCAL company folder has grown large could switch to shared-mode sync
|
|
4
|
+
* to pull fewer files.
|
|
5
|
+
*
|
|
6
|
+
* `all` is the DEFAULT sync mode (see `DEFAULT_MEMBERSHIP_SYNC_MODE` in
|
|
7
|
+
* hq-pro): a member who can see a company gets that company's files. Shared
|
|
8
|
+
* mode is a deliberate, opt-in NARROWING — worth suggesting only once the
|
|
9
|
+
* folder is big enough that pulling all of it actually costs disk/bandwidth.
|
|
10
|
+
* So the nudge is SIZE-GATED: below the threshold (default 5 GiB, see
|
|
11
|
+
* `DEFAULT_NARROW_HINT_MIN_BYTES`) no banner is ever shown, and an all-mode
|
|
12
|
+
* membership with a small folder is left completely alone.
|
|
4
13
|
*
|
|
5
14
|
* Emitted from `hq sync pull --all` and `hq sync now` after the per-target
|
|
6
15
|
* fanout resolves each membership's sync config. Suppressed when:
|
|
7
16
|
*
|
|
8
17
|
* - the membership is NOT on `syncMode: 'all'` (shared / custom users
|
|
9
18
|
* have already opted in to narrowing, so there is nothing to nudge),
|
|
19
|
+
* - the local `companies/<slug>/` folder is under the size threshold
|
|
20
|
+
* (the primary gate — the call site computes this and only emits when
|
|
21
|
+
* it is exceeded),
|
|
10
22
|
* - the env var `HQ_SYNC_NARROW_HINT=off` is set,
|
|
11
23
|
* - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
|
|
12
24
|
* `syncNarrowHint: 'off'`,
|
|
@@ -16,9 +28,9 @@
|
|
|
16
28
|
* banner per company per level).
|
|
17
29
|
*
|
|
18
30
|
* Three escalating levels — `'hint' | 'warning' | 'strict'`. Today's
|
|
19
|
-
* default level is `'hint'`;
|
|
20
|
-
*
|
|
21
|
-
*
|
|
31
|
+
* default level is `'hint'`; the plumbing lets an install escalate a large
|
|
32
|
+
* folder toward shared mode without re-touching the call sites. The size
|
|
33
|
+
* gate applies to every level: strict never refuses a small folder.
|
|
22
34
|
*
|
|
23
35
|
* - hint → dim suggestion to stderr, never blocks.
|
|
24
36
|
* - warning → yellow note to stderr, never blocks.
|
|
@@ -29,15 +41,33 @@
|
|
|
29
41
|
*
|
|
30
42
|
* The level is selected by the caller (typically from the
|
|
31
43
|
* `HQ_SYNC_NARROW_HINT_LEVEL` env var). See `resolveBannerLevel` for the
|
|
32
|
-
* default-and-override ladder.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* TODO(hq-core-staging release N+3): bump default level to 'strict' and
|
|
36
|
-
* wire `--mode-all` as the only opt-out.
|
|
44
|
+
* default-and-override ladder. The size threshold is overridable per install
|
|
45
|
+
* via `HQ_SYNC_NARROW_HINT_MIN_BYTES` or `.hq/config.json`
|
|
46
|
+
* `syncNarrowHintMinBytes` — see `resolveNarrowHintMinBytes`.
|
|
37
47
|
*/
|
|
38
48
|
import chalk from "chalk";
|
|
39
49
|
import * as fs from "node:fs";
|
|
40
50
|
import * as path from "node:path";
|
|
51
|
+
/**
|
|
52
|
+
* Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
|
|
53
|
+
* smaller than this is cheap to keep in full, so all-mode is left alone and no
|
|
54
|
+
* banner is shown. Overridable per install — see `resolveNarrowHintMinBytes`.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_NARROW_HINT_MIN_BYTES = 5 * 1024 * 1024 * 1024;
|
|
57
|
+
/** Render a byte count as a short human string, e.g. `6.2 GB`. */
|
|
58
|
+
function formatBytes(bytes) {
|
|
59
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
60
|
+
return "";
|
|
61
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
62
|
+
let value = bytes;
|
|
63
|
+
let unit = 0;
|
|
64
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
65
|
+
value /= 1024;
|
|
66
|
+
unit += 1;
|
|
67
|
+
}
|
|
68
|
+
const rounded = unit === 0 ? String(value) : value.toFixed(1);
|
|
69
|
+
return `${rounded} ${units[unit]}`;
|
|
70
|
+
}
|
|
41
71
|
const SHOWN = new Set();
|
|
42
72
|
/**
|
|
43
73
|
* Decides whether a banner should be printed AT ALL — independent of
|
|
@@ -86,11 +116,119 @@ export function resolveBannerLevel(envValue = process.env.HQ_SYNC_NARROW_HINT_LE
|
|
|
86
116
|
return v;
|
|
87
117
|
return "hint";
|
|
88
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the size threshold (in bytes) above which the narrow nudge fires.
|
|
121
|
+
* Precedence, first match wins:
|
|
122
|
+
*
|
|
123
|
+
* 1. `HQ_SYNC_NARROW_HINT_MIN_BYTES` env var (integer bytes),
|
|
124
|
+
* 2. `<hqRoot>/.hq/config.json` → `syncNarrowHintMinBytes` (integer bytes),
|
|
125
|
+
* 3. `DEFAULT_NARROW_HINT_MIN_BYTES` (5 GiB).
|
|
126
|
+
*
|
|
127
|
+
* A non-integer, negative, or otherwise unparseable override is ignored (falls
|
|
128
|
+
* through to the next source) rather than throwing — this is operator-facing
|
|
129
|
+
* config and a typo must not break a sync.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveNarrowHintMinBytes(opts = {}) {
|
|
132
|
+
const fromEnv = parsePositiveInt(opts.envValue !== undefined
|
|
133
|
+
? opts.envValue
|
|
134
|
+
: process.env.HQ_SYNC_NARROW_HINT_MIN_BYTES);
|
|
135
|
+
if (fromEnv !== null)
|
|
136
|
+
return fromEnv;
|
|
137
|
+
if (opts.hqRoot) {
|
|
138
|
+
const configPath = path.join(opts.hqRoot, ".hq", "config.json");
|
|
139
|
+
const exists = opts.existsFile ?? fs.existsSync;
|
|
140
|
+
const read = opts.readFile ?? ((p) => fs.readFileSync(p, "utf-8"));
|
|
141
|
+
if (exists(configPath)) {
|
|
142
|
+
try {
|
|
143
|
+
const cfg = JSON.parse(read(configPath));
|
|
144
|
+
const fromCfg = parsePositiveInt(cfg.syncNarrowHintMinBytes);
|
|
145
|
+
if (fromCfg !== null)
|
|
146
|
+
return fromCfg;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Malformed config → fall through to the default.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return DEFAULT_NARROW_HINT_MIN_BYTES;
|
|
154
|
+
}
|
|
155
|
+
/** A finite, non-negative integer parsed from a string/number, else null. */
|
|
156
|
+
function parsePositiveInt(value) {
|
|
157
|
+
if (typeof value === "number") {
|
|
158
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
159
|
+
}
|
|
160
|
+
if (typeof value !== "string")
|
|
161
|
+
return null;
|
|
162
|
+
const trimmed = value.trim();
|
|
163
|
+
if (!/^\d+$/.test(trimmed))
|
|
164
|
+
return null;
|
|
165
|
+
const n = Number(trimmed);
|
|
166
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Does the on-disk `companies/<slug>/` folder meet or exceed `thresholdBytes`?
|
|
170
|
+
*
|
|
171
|
+
* Walks the tree summing regular-file sizes and SHORT-CIRCUITS the instant the
|
|
172
|
+
* running total reaches the threshold, so a huge folder costs only enough
|
|
173
|
+
* `stat`s to cross the line rather than a full enumeration. Symlinks are
|
|
174
|
+
* counted by their own (link) size and never followed, so a symlink cycle
|
|
175
|
+
* cannot wedge the walk.
|
|
176
|
+
*
|
|
177
|
+
* Best-effort: a missing folder (never synced yet), a permission error, or any
|
|
178
|
+
* other I/O fault resolves to `false`. Not being able to prove a folder is
|
|
179
|
+
* large means we do NOT nag — the nudge is a convenience, never a blocker.
|
|
180
|
+
*/
|
|
181
|
+
export function companyFolderExceedsThreshold(companyDir, thresholdBytes, deps = {}) {
|
|
182
|
+
if (thresholdBytes <= 0)
|
|
183
|
+
return true;
|
|
184
|
+
const readdir = deps.readdir ??
|
|
185
|
+
((p) => fs.readdirSync(p, { withFileTypes: true }));
|
|
186
|
+
const lstat = deps.lstat ?? ((p) => fs.lstatSync(p));
|
|
187
|
+
let total = 0;
|
|
188
|
+
const stack = [companyDir];
|
|
189
|
+
while (stack.length > 0) {
|
|
190
|
+
const dir = stack.pop();
|
|
191
|
+
let entries;
|
|
192
|
+
try {
|
|
193
|
+
entries = readdir(dir);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Unreadable directory (missing / no permission) — skip it, don't abort
|
|
197
|
+
// the whole measurement over one bad subtree.
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
const full = path.join(dir, entry.name);
|
|
202
|
+
if (entry.isDirectory()) {
|
|
203
|
+
stack.push(full);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// Symlinks and regular files alike: count the link/file's own size,
|
|
207
|
+
// never follow (isDirectory() above already excluded real dirs; a
|
|
208
|
+
// symlink-to-dir is intentionally treated as a leaf).
|
|
209
|
+
try {
|
|
210
|
+
total += lstat(full).size;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Vanished between readdir and lstat — ignore.
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (total >= thresholdBytes)
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
89
222
|
/**
|
|
90
223
|
* Returns `true` when the strict-mode rollout has been opted into AND
|
|
91
224
|
* the membership in question is still on `'all'`. Call sites should
|
|
92
225
|
* refuse to proceed (exit non-zero) when this returns true and the
|
|
93
226
|
* operator hasn't passed `--mode-all`.
|
|
227
|
+
*
|
|
228
|
+
* NOTE: this does NOT encode the size gate — the size gate is a separate,
|
|
229
|
+
* mandatory precondition the call site checks FIRST (see
|
|
230
|
+
* `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
|
|
231
|
+
* folder is under the threshold is never refused.
|
|
94
232
|
*/
|
|
95
233
|
export function isStrictRefusal(syncMode, level) {
|
|
96
234
|
return level === "strict" && syncMode === "all";
|
|
@@ -116,23 +254,28 @@ export function emitNarrowHint(input, opts = {}) {
|
|
|
116
254
|
return;
|
|
117
255
|
SHOWN.add(key);
|
|
118
256
|
const write = opts.write ?? ((s) => process.stderr.write(s + "\n"));
|
|
257
|
+
// The nudge only reaches here for a folder past the size gate, so every
|
|
258
|
+
// message leads with the size fact. `folderBytes` is optional/cosmetic.
|
|
259
|
+
const sizePhrase = typeof input.folderBytes === "number"
|
|
260
|
+
? `has grown to ~${formatBytes(input.folderBytes)}`
|
|
261
|
+
: "has grown large";
|
|
119
262
|
if (input.level === "hint") {
|
|
120
|
-
write(chalk.dim(
|
|
121
|
-
"
|
|
263
|
+
write(chalk.dim(`Tip: this company's local folder ${sizePhrase}. You can switch to ` +
|
|
264
|
+
"shared-mode sync to only pull files shared with you — run " +
|
|
265
|
+
"`hq sync narrow --dry-run` to preview."));
|
|
122
266
|
return;
|
|
123
267
|
}
|
|
124
268
|
if (input.level === "warning") {
|
|
125
|
-
write(chalk.yellow(
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"default to strict."));
|
|
269
|
+
write(chalk.yellow(`Warning: this company's local folder ${sizePhrase} and is syncing in ` +
|
|
270
|
+
"full. Consider shared-mode sync to pull only what's shared with " +
|
|
271
|
+
"you — run `hq sync narrow --dry-run` to preview the migration."));
|
|
129
272
|
return;
|
|
130
273
|
}
|
|
131
274
|
// strict — caller is responsible for refusing to proceed unless
|
|
132
275
|
// --mode-all was passed. We only emit the message here.
|
|
133
|
-
write(chalk.red(
|
|
134
|
-
"
|
|
135
|
-
"to
|
|
276
|
+
write(chalk.red(`Error: this company's local folder ${sizePhrase}; all-mode sync is ` +
|
|
277
|
+
"blocked for it. Pass --mode-all to keep pulling everything this run, " +
|
|
278
|
+
"or run `hq sync narrow --apply` to switch to shared mode."));
|
|
136
279
|
}
|
|
137
280
|
/** Test-only helper — clears the per-process dedupe set. */
|
|
138
281
|
export function _resetShownForTests() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.85.
|
|
3
|
+
"version": "5.85.3",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
31
31
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
32
|
-
"@indigoai-us/hq-cloud": "^6.14.
|
|
32
|
+
"@indigoai-us/hq-cloud": "^6.14.45",
|
|
33
33
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
34
34
|
"@sentry/node": "^10.49.0",
|
|
35
35
|
"better-sqlite3": "^12.11.1",
|