@indigoai-us/hq-cli 5.85.2 → 5.86.0
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 +22 -0
- package/dist/commands/cloud.js +51 -14
- package/dist/commands/index-cmd.d.ts +16 -0
- package/dist/commands/index-cmd.js +76 -0
- package/dist/commands/search.d.ts +12 -0
- package/dist/commands/search.js +48 -0
- 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/dist/lib/search-index/index.d.ts +58 -0
- package/dist/lib/search-index/index.js +197 -0
- package/dist/main.js +6 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.86.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `hq search` and `hq index` commands for keyword, semantic, and hybrid
|
|
10
|
+
search across reconciled QMD collections, with explicit opt-in embedding and
|
|
11
|
+
package-local QMD resolution. Registered unmanaged collections remain
|
|
12
|
+
untouched and are reported as `registered (unmanaged)`. (#306)
|
|
13
|
+
|
|
14
|
+
## [5.85.3]
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The shared-mode sync nudge is now size-gated: it appears only once a
|
|
19
|
+
company's local folder crosses ~5 GiB (overridable via
|
|
20
|
+
`HQ_SYNC_NARROW_HINT_MIN_BYTES` or `.hq/config.json`
|
|
21
|
+
`syncNarrowHintMinBytes`), instead of nudging every all-mode member toward
|
|
22
|
+
shared mode. Below the threshold an all-mode membership is left alone — no
|
|
23
|
+
banner, and in strict mode no refusal. `hq sync mode` now also reports `all`
|
|
24
|
+
(the effective default) rather than `shared` when a config fetch fails.
|
|
25
|
+
(#303)
|
|
26
|
+
|
|
5
27
|
## [5.85.2]
|
|
6
28
|
|
|
7
29
|
### 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,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { type RunQmdOptions, type SearchCollection, type QmdProcessResult } from '../lib/search-index/index.js';
|
|
3
|
+
export type SearchIndexDependencies = {
|
|
4
|
+
reconcileCollections: (hqRoot: string) => unknown;
|
|
5
|
+
deriveCollections: (hqRoot: string) => SearchCollection[];
|
|
6
|
+
listRegisteredCollections: (hqRoot: string, options?: RunQmdOptions) => Set<string>;
|
|
7
|
+
resolveQmdBin: () => string;
|
|
8
|
+
resolveQmdVersion: () => string | undefined;
|
|
9
|
+
runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
|
|
10
|
+
};
|
|
11
|
+
/** Incrementally update qmd, embedding only when an operator explicitly asks. */
|
|
12
|
+
export declare function syncSearchIndex(hqRoot: string, embed: boolean, dependencies?: SearchIndexDependencies): void;
|
|
13
|
+
export declare function collectionStatusLines(expected: SearchCollection[], registered: ReadonlySet<string>): string[];
|
|
14
|
+
export declare function collectionSummary(expected: SearchCollection[], registered: ReadonlySet<string>): string;
|
|
15
|
+
export declare function registerIndexCommand(program: Command, dependencies?: SearchIndexDependencies): void;
|
|
16
|
+
//# sourceMappingURL=index-cmd.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { deriveCollections, listRegisteredCollections, reconcileCollections, resolveQmdBin, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
|
|
2
|
+
import { findHqRoot } from '../utils/manifest.js';
|
|
3
|
+
const defaults = {
|
|
4
|
+
reconcileCollections,
|
|
5
|
+
deriveCollections,
|
|
6
|
+
listRegisteredCollections,
|
|
7
|
+
resolveQmdBin,
|
|
8
|
+
resolveQmdVersion,
|
|
9
|
+
runQmd,
|
|
10
|
+
};
|
|
11
|
+
/** Incrementally update qmd, embedding only when an operator explicitly asks. */
|
|
12
|
+
export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
|
|
13
|
+
dependencies.reconcileCollections(hqRoot);
|
|
14
|
+
dependencies.runQmd(['update'], { cwd: hqRoot });
|
|
15
|
+
if (embed)
|
|
16
|
+
dependencies.runQmd(['embed'], { cwd: hqRoot });
|
|
17
|
+
}
|
|
18
|
+
function resolveRoot(hqRoot) {
|
|
19
|
+
return hqRoot ?? findHqRoot();
|
|
20
|
+
}
|
|
21
|
+
export function collectionStatusLines(expected, registered) {
|
|
22
|
+
const expectedNames = new Set(expected.map((collection) => collection.name));
|
|
23
|
+
const managed = expected.map((collection) => `${registered.has(collection.name) ? 'registered' : 'missing'} ${collection.name} ${collection.path}`);
|
|
24
|
+
const unmanaged = [...registered]
|
|
25
|
+
.filter((name) => !expectedNames.has(name))
|
|
26
|
+
.sort((left, right) => left.localeCompare(right))
|
|
27
|
+
.map((name) => `registered (unmanaged) ${name}`);
|
|
28
|
+
return [...managed, ...unmanaged];
|
|
29
|
+
}
|
|
30
|
+
export function collectionSummary(expected, registered) {
|
|
31
|
+
const expectedNames = new Set(expected.map((collection) => collection.name));
|
|
32
|
+
const unmanaged = [...registered].filter((name) => !expectedNames.has(name)).length;
|
|
33
|
+
return `collections: ${registered.size} registered; ${expected.length} expected; ${unmanaged} unmanaged`;
|
|
34
|
+
}
|
|
35
|
+
export function registerIndexCommand(program, dependencies = defaults) {
|
|
36
|
+
const index = program.command('index').description('Manage the local HQ search index');
|
|
37
|
+
index
|
|
38
|
+
.command('sync')
|
|
39
|
+
.description('Reconcile collections and incrementally update the qmd index')
|
|
40
|
+
.option('--embed', 'Also rebuild expensive semantic embeddings')
|
|
41
|
+
.option('--hq-root <path>', 'HQ root to index (defaults to auto-detected root)')
|
|
42
|
+
.action((options) => {
|
|
43
|
+
const hqRoot = resolveRoot(options.hqRoot);
|
|
44
|
+
syncSearchIndex(hqRoot, options.embed === true);
|
|
45
|
+
console.log(`Updated search index for ${hqRoot}${options.embed ? ' (including embeddings)' : ''}.`);
|
|
46
|
+
});
|
|
47
|
+
index
|
|
48
|
+
.command('collections')
|
|
49
|
+
.description('Show expected and registered qmd collections')
|
|
50
|
+
.option('--hq-root <path>', 'HQ root to inspect (defaults to auto-detected root)')
|
|
51
|
+
.action((options) => {
|
|
52
|
+
const hqRoot = resolveRoot(options.hqRoot);
|
|
53
|
+
const registered = dependencies.listRegisteredCollections(hqRoot);
|
|
54
|
+
for (const line of collectionStatusLines(dependencies.deriveCollections(hqRoot), registered))
|
|
55
|
+
console.log(line);
|
|
56
|
+
});
|
|
57
|
+
index
|
|
58
|
+
.command('status')
|
|
59
|
+
.description('Show qmd binary, collection, and index status')
|
|
60
|
+
.option('--hq-root <path>', 'HQ root to inspect (defaults to auto-detected root)')
|
|
61
|
+
.action((options) => {
|
|
62
|
+
const hqRoot = resolveRoot(options.hqRoot);
|
|
63
|
+
const bin = dependencies.resolveQmdBin();
|
|
64
|
+
const registered = dependencies.listRegisteredCollections(hqRoot, { bin, cwd: hqRoot });
|
|
65
|
+
const expected = dependencies.deriveCollections(hqRoot);
|
|
66
|
+
const qmdStatus = dependencies.runQmd(['status'], { bin, cwd: hqRoot });
|
|
67
|
+
const qmdVersion = dependencies.resolveQmdVersion();
|
|
68
|
+
console.log(`qmd: ${bin}${qmdVersion ? ` (version ${qmdVersion})` : ''}`);
|
|
69
|
+
console.log(collectionSummary(expected, registered));
|
|
70
|
+
if (qmdStatus.stdout)
|
|
71
|
+
process.stdout.write(qmdStatus.stdout);
|
|
72
|
+
if (qmdStatus.stderr)
|
|
73
|
+
process.stderr.write(qmdStatus.stderr);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=index-cmd.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
export type SearchMode = 'keyword' | 'semantic' | 'hybrid';
|
|
3
|
+
export type SearchOptions = {
|
|
4
|
+
mode?: SearchMode;
|
|
5
|
+
collection?: string;
|
|
6
|
+
count?: number;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function buildSearchArgs(query: string, options?: SearchOptions): string[];
|
|
10
|
+
export declare function buildGetArgs(document: string, options?: Pick<SearchOptions, 'collection'>): string[];
|
|
11
|
+
export declare function registerSearchCommand(program: Command): void;
|
|
12
|
+
//# sourceMappingURL=search.d.ts.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { runQmd } from '../lib/search-index/index.js';
|
|
2
|
+
export function buildSearchArgs(query, options = {}) {
|
|
3
|
+
const command = { keyword: 'search', semantic: 'vsearch', hybrid: 'query' }[options.mode ?? 'keyword'];
|
|
4
|
+
const args = [command, query];
|
|
5
|
+
if (options.collection)
|
|
6
|
+
args.push('-c', options.collection);
|
|
7
|
+
if (options.count !== undefined)
|
|
8
|
+
args.push('-n', String(options.count));
|
|
9
|
+
if (options.json)
|
|
10
|
+
args.push('--json');
|
|
11
|
+
return args;
|
|
12
|
+
}
|
|
13
|
+
export function buildGetArgs(document, options = {}) {
|
|
14
|
+
const args = ['get', document];
|
|
15
|
+
if (options.collection)
|
|
16
|
+
args.push('-c', options.collection);
|
|
17
|
+
return args;
|
|
18
|
+
}
|
|
19
|
+
function relay(result) {
|
|
20
|
+
if (result.stdout)
|
|
21
|
+
process.stdout.write(result.stdout);
|
|
22
|
+
if (result.stderr)
|
|
23
|
+
process.stderr.write(result.stderr);
|
|
24
|
+
}
|
|
25
|
+
export function registerSearchCommand(program) {
|
|
26
|
+
const search = program.command('search').description('Search the local HQ qmd index');
|
|
27
|
+
search
|
|
28
|
+
.command('get <document>')
|
|
29
|
+
.description('Retrieve a qmd document by path or document id')
|
|
30
|
+
.option('-c, --collection <collection>', 'Restrict retrieval to a collection')
|
|
31
|
+
.action((document, options) => {
|
|
32
|
+
relay(runQmd(buildGetArgs(document, options)));
|
|
33
|
+
});
|
|
34
|
+
search
|
|
35
|
+
.command('<query>')
|
|
36
|
+
.description('Search the local HQ qmd index')
|
|
37
|
+
.option('--mode <mode>', 'Search mode: keyword, semantic, or hybrid', 'keyword')
|
|
38
|
+
.option('-c, --collection <collection>', 'Restrict search to a collection')
|
|
39
|
+
.option('-n, --count <count>', 'Maximum result count', (value) => Number(value))
|
|
40
|
+
.option('--json', 'Request machine-readable qmd output')
|
|
41
|
+
.action((query, options) => {
|
|
42
|
+
if (!['keyword', 'semantic', 'hybrid'].includes(options.mode ?? 'keyword')) {
|
|
43
|
+
throw new Error(`Unknown search mode '${options.mode}'. Expected keyword, semantic, or hybrid.`);
|
|
44
|
+
}
|
|
45
|
+
relay(runQmd(buildSearchArgs(query, options)));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=search.js.map
|
|
@@ -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() {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type QmdProcessResult = {
|
|
2
|
+
status: number | null;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
error?: Error;
|
|
6
|
+
};
|
|
7
|
+
export type QmdProcessRunner = (bin: string, args: string[], options: {
|
|
8
|
+
cwd?: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}) => QmdProcessResult;
|
|
11
|
+
export declare class QmdBinaryMissingError extends Error {
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class QmdExitError extends Error {
|
|
15
|
+
readonly args: string[];
|
|
16
|
+
readonly status: number | null;
|
|
17
|
+
readonly stdout: string;
|
|
18
|
+
readonly stderr: string;
|
|
19
|
+
name: string;
|
|
20
|
+
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string);
|
|
21
|
+
}
|
|
22
|
+
export declare class QmdCollectionMissingError extends QmdExitError {
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
export type ResolveQmdBinOptions = {
|
|
26
|
+
env?: Record<string, string | undefined>;
|
|
27
|
+
isExecutable?: (candidate: string) => boolean;
|
|
28
|
+
packageBin?: () => string | undefined;
|
|
29
|
+
pathBin?: () => string | undefined;
|
|
30
|
+
};
|
|
31
|
+
/** Return the pinned package version when qmd is supplied by this CLI. */
|
|
32
|
+
export declare function resolveQmdVersion(): string | undefined;
|
|
33
|
+
/** Resolve qmd without relying on a globally installed copy. */
|
|
34
|
+
export declare function resolveQmdBin(options?: ResolveQmdBinOptions): string;
|
|
35
|
+
export type RunQmdOptions = {
|
|
36
|
+
bin?: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
runner?: QmdProcessRunner;
|
|
40
|
+
};
|
|
41
|
+
/** Run qmd with captured output and typed failures. */
|
|
42
|
+
export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
|
|
43
|
+
export type SearchCollection = {
|
|
44
|
+
name: string;
|
|
45
|
+
path: string;
|
|
46
|
+
mask: string;
|
|
47
|
+
context: string;
|
|
48
|
+
};
|
|
49
|
+
/** Derive the local qmd collection policy for one HQ tree. */
|
|
50
|
+
export declare function deriveCollections(hqRoot: string): SearchCollection[];
|
|
51
|
+
export type ReconcileCollectionsOptions = {
|
|
52
|
+
bin?: string;
|
|
53
|
+
runner?: QmdProcessRunner;
|
|
54
|
+
};
|
|
55
|
+
/** Register expected collections that qmd does not yet know about. */
|
|
56
|
+
export declare function reconcileCollections(hqRoot: string, options?: ReconcileCollectionsOptions): SearchCollection[];
|
|
57
|
+
export declare function listRegisteredCollections(hqRoot: string, options?: RunQmdOptions): Set<string>;
|
|
58
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
export class QmdBinaryMissingError extends Error {
|
|
7
|
+
name = 'QmdBinaryMissingError';
|
|
8
|
+
}
|
|
9
|
+
export class QmdExitError extends Error {
|
|
10
|
+
args;
|
|
11
|
+
status;
|
|
12
|
+
stdout;
|
|
13
|
+
stderr;
|
|
14
|
+
name = 'QmdExitError';
|
|
15
|
+
constructor(message, args, status, stdout, stderr) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.args = args;
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.stdout = stdout;
|
|
20
|
+
this.stderr = stderr;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class QmdCollectionMissingError extends QmdExitError {
|
|
24
|
+
name = 'QmdCollectionMissingError';
|
|
25
|
+
}
|
|
26
|
+
function isExecutable(candidate) {
|
|
27
|
+
try {
|
|
28
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function packageLocalBin() {
|
|
36
|
+
try {
|
|
37
|
+
const packageJson = require.resolve('@tobilu/qmd/package.json');
|
|
38
|
+
return path.join(path.dirname(packageJson), 'qmd');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Return the pinned package version when qmd is supplied by this CLI. */
|
|
45
|
+
export function resolveQmdVersion() {
|
|
46
|
+
try {
|
|
47
|
+
const packageJson = require('@tobilu/qmd/package.json');
|
|
48
|
+
return typeof packageJson.version === 'string' ? packageJson.version : undefined;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function pathBin() {
|
|
55
|
+
const paths = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
|
|
56
|
+
const names = process.platform === 'win32' ? ['qmd.exe', 'qmd.cmd', 'qmd'] : ['qmd'];
|
|
57
|
+
for (const directory of paths) {
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const candidate = path.join(directory, name);
|
|
60
|
+
if (isExecutable(candidate))
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Resolve qmd without relying on a globally installed copy. */
|
|
67
|
+
export function resolveQmdBin(options = {}) {
|
|
68
|
+
const env = options.env ?? process.env;
|
|
69
|
+
const executable = options.isExecutable ?? isExecutable;
|
|
70
|
+
const probes = [];
|
|
71
|
+
const override = env.HQ_QMD_BIN;
|
|
72
|
+
if (override) {
|
|
73
|
+
if (executable(override))
|
|
74
|
+
return override;
|
|
75
|
+
probes.push(`HQ_QMD_BIN (${override})`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
probes.push('HQ_QMD_BIN (not set)');
|
|
79
|
+
}
|
|
80
|
+
const installed = (options.packageBin ?? packageLocalBin)();
|
|
81
|
+
if (installed && executable(installed))
|
|
82
|
+
return installed;
|
|
83
|
+
probes.push(`package-local @tobilu/qmd (${installed ?? 'not found'})`);
|
|
84
|
+
const onPath = (options.pathBin ?? pathBin)();
|
|
85
|
+
if (onPath && executable(onPath))
|
|
86
|
+
return onPath;
|
|
87
|
+
probes.push(`qmd on PATH (${onPath ?? 'not found'})`);
|
|
88
|
+
throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
|
|
89
|
+
}
|
|
90
|
+
function defaultRunner(bin, args, options) {
|
|
91
|
+
const result = spawnSync(bin, args, { cwd: options.cwd, env: options.env, encoding: 'utf8' });
|
|
92
|
+
return {
|
|
93
|
+
status: result.status,
|
|
94
|
+
stdout: result.stdout ?? '',
|
|
95
|
+
stderr: result.stderr ?? '',
|
|
96
|
+
error: result.error,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Run qmd with captured output and typed failures. */
|
|
100
|
+
export function runQmd(args, options = {}) {
|
|
101
|
+
const bin = options.bin ?? resolveQmdBin({ env: options.env });
|
|
102
|
+
const result = (options.runner ?? defaultRunner)(bin, args, { cwd: options.cwd, env: options.env });
|
|
103
|
+
if (result.error) {
|
|
104
|
+
throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${result.error.message}`);
|
|
105
|
+
}
|
|
106
|
+
if (result.status === 0)
|
|
107
|
+
return result;
|
|
108
|
+
const detail = result.stderr || result.stdout || 'qmd returned no diagnostic output';
|
|
109
|
+
const message = `qmd ${args.join(' ')} exited with ${result.status ?? 'an unknown status'}: ${detail}`;
|
|
110
|
+
if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
|
|
111
|
+
throw new QmdCollectionMissingError(message, args, result.status, result.stdout, result.stderr);
|
|
112
|
+
}
|
|
113
|
+
throw new QmdExitError(message, args, result.status, result.stdout, result.stderr);
|
|
114
|
+
}
|
|
115
|
+
function containsIndexedMarkdown(directory) {
|
|
116
|
+
if (!fs.existsSync(directory))
|
|
117
|
+
return false;
|
|
118
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
119
|
+
const child = path.join(directory, entry.name);
|
|
120
|
+
if (entry.isDirectory() && containsIndexedMarkdown(child))
|
|
121
|
+
return true;
|
|
122
|
+
if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'INDEX.md')
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
function containsProjectSource(directory) {
|
|
128
|
+
if (!fs.existsSync(directory))
|
|
129
|
+
return false;
|
|
130
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
131
|
+
const child = path.join(directory, entry.name);
|
|
132
|
+
if (entry.isDirectory() && containsProjectSource(child))
|
|
133
|
+
return true;
|
|
134
|
+
if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.json')))
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
/** Derive the local qmd collection policy for one HQ tree. */
|
|
140
|
+
export function deriveCollections(hqRoot) {
|
|
141
|
+
const root = path.resolve(hqRoot);
|
|
142
|
+
const companiesDir = path.join(root, 'companies');
|
|
143
|
+
const companies = fs.existsSync(companiesDir)
|
|
144
|
+
? fs.readdirSync(companiesDir, { withFileTypes: true })
|
|
145
|
+
.filter((entry) => entry.isDirectory())
|
|
146
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
147
|
+
: [];
|
|
148
|
+
const collections = [];
|
|
149
|
+
for (const entry of companies) {
|
|
150
|
+
const knowledge = path.join(companiesDir, entry.name, 'knowledge');
|
|
151
|
+
if (!containsIndexedMarkdown(knowledge))
|
|
152
|
+
continue;
|
|
153
|
+
collections.push({
|
|
154
|
+
name: entry.name,
|
|
155
|
+
path: knowledge,
|
|
156
|
+
mask: '**/*.md',
|
|
157
|
+
context: `Knowledge base for ${entry.name}.`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
for (const entry of companies) {
|
|
161
|
+
const projects = path.join(companiesDir, entry.name, 'projects');
|
|
162
|
+
if (!containsProjectSource(projects))
|
|
163
|
+
continue;
|
|
164
|
+
collections.push({
|
|
165
|
+
name: `${entry.name}-projects`,
|
|
166
|
+
path: projects,
|
|
167
|
+
mask: '**/*.{md,json}',
|
|
168
|
+
context: `Project PRDs and documentation for ${entry.name}.`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const personalKnowledge = path.join(root, 'personal', 'knowledge');
|
|
172
|
+
if (containsIndexedMarkdown(personalKnowledge)) {
|
|
173
|
+
collections.push({
|
|
174
|
+
name: 'personal-knowledge',
|
|
175
|
+
path: personalKnowledge,
|
|
176
|
+
mask: '**/*.md',
|
|
177
|
+
context: 'Personal knowledge base (owner overlay).',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return collections;
|
|
181
|
+
}
|
|
182
|
+
/** Register expected collections that qmd does not yet know about. */
|
|
183
|
+
export function reconcileCollections(hqRoot, options = {}) {
|
|
184
|
+
const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
|
|
185
|
+
const registered = listRegisteredCollections(hqRoot, runOptions);
|
|
186
|
+
const missing = deriveCollections(hqRoot).filter((collection) => !registered.has(collection.name));
|
|
187
|
+
for (const collection of missing) {
|
|
188
|
+
runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
|
|
189
|
+
runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
|
|
190
|
+
}
|
|
191
|
+
return missing;
|
|
192
|
+
}
|
|
193
|
+
export function listRegisteredCollections(hqRoot, options = {}) {
|
|
194
|
+
const result = runQmd(['collection', 'list'], { ...options, cwd: options.cwd ?? hqRoot });
|
|
195
|
+
return new Set([...result.stdout.matchAll(/qmd:\/\/([^/\s]+)/g)].map((match) => match[1]));
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=index.js.map
|
package/dist/main.js
CHANGED
|
@@ -57,6 +57,8 @@ import { registerOutpostsCommand } from "./commands/outposts.js";
|
|
|
57
57
|
import { registerBillingCommand } from "./commands/billing.js";
|
|
58
58
|
import { registerDbCommand } from "./commands/db.js";
|
|
59
59
|
import { registerCoreCommands } from "./commands/core.js";
|
|
60
|
+
import { registerSearchCommand } from "./commands/search.js";
|
|
61
|
+
import { registerIndexCommand } from "./commands/index-cmd.js";
|
|
60
62
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
61
63
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
62
64
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
@@ -228,6 +230,10 @@ registerBillingCommand(program);
|
|
|
228
230
|
// the source-root entries are maintainer tools that must never touch a live
|
|
229
231
|
// install. Registered from a manifest in the module, not wired per script here.
|
|
230
232
|
registerCoreCommands(program);
|
|
233
|
+
// Local qmd search and index management. Kept distinct from `hq reindex`,
|
|
234
|
+
// which converges scaffold-owned files and hooks rather than search data.
|
|
235
|
+
registerSearchCommand(program);
|
|
236
|
+
registerIndexCommand(program);
|
|
231
237
|
program.hook("preAction", async () => {
|
|
232
238
|
await emitCliSessionStarted();
|
|
233
239
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.86.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
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
|
+
"@tobilu/qmd": "1.0.7",
|
|
35
36
|
"better-sqlite3": "^12.11.1",
|
|
36
37
|
"chalk": "^5.3.0",
|
|
37
38
|
"commander": "^12.1.0",
|