@indigoai-us/hq-cli 5.101.4 → 5.101.6
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/mesh.d.ts +12 -0
- package/dist/commands/mesh.js +173 -0
- package/dist/lib/mesh/api.d.ts +88 -0
- package/dist/lib/mesh/api.js +319 -0
- package/dist/lib/mesh/cache.d.ts +10 -0
- package/dist/lib/mesh/cache.js +42 -0
- package/dist/main.js +24 -2
- package/dist/utils/qmd-collection-missing-error.d.ts +10 -0
- package/dist/utils/qmd-collection-missing-error.js +95 -0
- package/package.json +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [5.101.6] — 2026-08-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`hq mesh`** — native work-mesh verbs in the CLI (Cognito + hq-pro REST +
|
|
10
|
+
`~/.hq/work-mesh/cache`). No pack helper required. Verbs: `check`
|
|
11
|
+
(`status` / `projects`), `start`, `progress`, `blocked`, `done`, `note`,
|
|
12
|
+
`story`, `doctor` (warms directory + inbox + pair DMs). Does **not** start
|
|
13
|
+
MQTT listen and is not `hq doctor` (hook guardrails). `--apply` Board PUTs
|
|
14
|
+
from local prd.json are not in the CLI yet.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `hq mesh doctor` now keys `directory` / `inbox` / `contacts` on the real
|
|
19
|
+
`prs_*` / `agt_*` principal. `custom:entityUid` is read from the Cognito ID
|
|
20
|
+
token first; if the JWT has only email/sub (typical human access tokens),
|
|
21
|
+
doctor falls back to `/v1/realtime/credentials` and keeps only the uid —
|
|
22
|
+
AWS IoT creds are discarded. Leftover `session.json` is removed once the
|
|
23
|
+
real principal is known.
|
|
24
|
+
|
|
25
|
+
## [5.101.5] — 2026-08-17
|
|
26
|
+
|
|
27
|
+
- No user-facing changes recorded.
|
|
28
|
+
|
|
3
29
|
## [5.101.4] — 2026-08-16
|
|
4
30
|
|
|
5
31
|
- No user-facing changes recorded.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq mesh` — work-mesh verbs for agents and local sessions.
|
|
3
|
+
*
|
|
4
|
+
* Native hq-cli: Cognito + hq-pro REST + ~/.hq/work-mesh/cache. Does not
|
|
5
|
+
* require hq-pack-work-mesh on disk and does not start MQTT listen.
|
|
6
|
+
* Distinct from `hq doctor` (hook guardrails).
|
|
7
|
+
*/
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { type MeshCompany, type MeshThread } from "../lib/mesh/api.js";
|
|
10
|
+
export declare function formatCheckLines(threads: MeshThread[], company: MeshCompany, projectId?: string): string[];
|
|
11
|
+
export declare function registerMeshCommand(program: Command): void;
|
|
12
|
+
//# sourceMappingURL=mesh.d.ts.map
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq mesh` — work-mesh verbs for agents and local sessions.
|
|
3
|
+
*
|
|
4
|
+
* Native hq-cli: Cognito + hq-pro REST + ~/.hq/work-mesh/cache. Does not
|
|
5
|
+
* require hq-pack-work-mesh on disk and does not start MQTT listen.
|
|
6
|
+
* Distinct from `hq doctor` (hook guardrails).
|
|
7
|
+
*/
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import { loadCachedTokens } from "../utils/cognito-session.js";
|
|
10
|
+
import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveThreads, patchStoryStatus, requireToken, resolveMeshCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
11
|
+
export function formatCheckLines(threads, company, projectId) {
|
|
12
|
+
if (threads.length === 0) {
|
|
13
|
+
return ["Work mesh: no active project threads found."];
|
|
14
|
+
}
|
|
15
|
+
const scope = projectId
|
|
16
|
+
? `${company.companySlug || company.companyUid}/${projectId}`
|
|
17
|
+
: company.companySlug || company.companyUid;
|
|
18
|
+
const lines = [`Work mesh: ${threads.length} active thread(s) for ${scope}`];
|
|
19
|
+
for (const thread of threads.slice(0, 8)) {
|
|
20
|
+
const owner = thread.ownerUid ? ` owner=${thread.ownerUid}` : "";
|
|
21
|
+
const summary = thread.progressSummary ||
|
|
22
|
+
thread.sourceSignalSummary ||
|
|
23
|
+
thread.blockedReason ||
|
|
24
|
+
"";
|
|
25
|
+
lines.push(`- ${thread.threadStatus ?? "unknown"} ${thread.threadId ?? "?"}${owner}${summary ? `: ${summary}` : ""}`);
|
|
26
|
+
}
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
function fail(message) {
|
|
30
|
+
console.error(chalk.red(message));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
function requireProject(opts) {
|
|
34
|
+
const project = opts.project?.trim();
|
|
35
|
+
if (!project)
|
|
36
|
+
fail("`--project <slug>` is required.");
|
|
37
|
+
return project;
|
|
38
|
+
}
|
|
39
|
+
async function withCompany(opts) {
|
|
40
|
+
const token = await requireToken();
|
|
41
|
+
const company = await resolveMeshCompany(token, opts.company);
|
|
42
|
+
return { token, company };
|
|
43
|
+
}
|
|
44
|
+
async function runCheck(opts) {
|
|
45
|
+
const { token, company } = await withCompany(opts);
|
|
46
|
+
const threads = await listActiveThreads(token, company.companyUid, opts.project);
|
|
47
|
+
if (opts.json) {
|
|
48
|
+
console.log(JSON.stringify({ ok: true, action: "check", company, projectId: opts.project, threads }, null, 2));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const line of formatCheckLines(threads, company, opts.project)) {
|
|
52
|
+
console.log(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function runEvent(verb, opts) {
|
|
56
|
+
const { token, company } = await withCompany(opts);
|
|
57
|
+
const projectId = requireProject(opts);
|
|
58
|
+
const ensured = await ensureProjectThread(token, company, projectId, {
|
|
59
|
+
threadId: opts.threadId,
|
|
60
|
+
summary: opts.summary,
|
|
61
|
+
});
|
|
62
|
+
const eventKind = verb === "start" ? "claim" : verb;
|
|
63
|
+
const event = await appendThreadEvent(token, company.companyUid, ensured.threadId, eventKind, eventPayload(eventKind, opts, callerLabelFromToken(token)));
|
|
64
|
+
if (opts.json) {
|
|
65
|
+
console.log(JSON.stringify({
|
|
66
|
+
ok: true,
|
|
67
|
+
action: verb,
|
|
68
|
+
eventKind,
|
|
69
|
+
company,
|
|
70
|
+
projectId,
|
|
71
|
+
threadId: ensured.threadId,
|
|
72
|
+
created: ensured.created,
|
|
73
|
+
...event,
|
|
74
|
+
}, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
console.log(`Work mesh: ${verb} ${ensured.threadId}${event.eventId ? ` (${event.eventId})` : ""}`);
|
|
78
|
+
}
|
|
79
|
+
async function runStory(opts) {
|
|
80
|
+
const storyId = opts.story?.trim();
|
|
81
|
+
const status = opts.status?.trim();
|
|
82
|
+
if (!storyId || !status || !STORY_STATUSES.has(status)) {
|
|
83
|
+
fail("story requires --story <id> and --status queued|in_progress|review|done");
|
|
84
|
+
}
|
|
85
|
+
const { token, company } = await withCompany(opts);
|
|
86
|
+
const projectId = requireProject(opts);
|
|
87
|
+
const patched = await patchStoryStatus(token, company.companyUid, projectId, storyId, status);
|
|
88
|
+
if (opts.json) {
|
|
89
|
+
console.log(JSON.stringify({ ok: true, action: "story", company, ...patched, storyId }, null, 2));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
console.log(`Work mesh: story ${storyId} → ${patched.status} (v${patched.version ?? "?"})`);
|
|
93
|
+
}
|
|
94
|
+
async function runDoctor(opts) {
|
|
95
|
+
if (opts.apply) {
|
|
96
|
+
fail("`hq mesh doctor --apply` (paced Board PUTs from local prd.json) is not in the CLI yet.\n" +
|
|
97
|
+
"Omit --apply to warm the conversation cache (directory, inbox, pair DMs).");
|
|
98
|
+
}
|
|
99
|
+
const token = await requireToken();
|
|
100
|
+
const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
|
|
101
|
+
const warmed = await warmMeshConversationCache(token, principalUid);
|
|
102
|
+
if (opts.json) {
|
|
103
|
+
console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed }, null, 2));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
console.log(`Work mesh cache warmed at ${warmed.cacheRoot}` +
|
|
107
|
+
` (directory=${warmed.directory} inbox=${warmed.inbox}` +
|
|
108
|
+
` contacts=${warmed.contacts} pair-threads=${warmed.threads})`);
|
|
109
|
+
}
|
|
110
|
+
function wrap(action) {
|
|
111
|
+
return async () => {
|
|
112
|
+
try {
|
|
113
|
+
await action();
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function collectRepeatable(value, previous = []) {
|
|
121
|
+
return previous.concat(value);
|
|
122
|
+
}
|
|
123
|
+
function addSharedFlags(cmd) {
|
|
124
|
+
return cmd
|
|
125
|
+
.option("--company <slug|uid>", "Company slug or cloud uid")
|
|
126
|
+
.option("--project <slug>", "HQ project slug / projectId")
|
|
127
|
+
.option("--thread-id <id>", "Explicit work thread id")
|
|
128
|
+
.option("--json", "Print machine-readable JSON");
|
|
129
|
+
}
|
|
130
|
+
export function registerMeshCommand(program) {
|
|
131
|
+
const mesh = program
|
|
132
|
+
.command("mesh")
|
|
133
|
+
.description("Work mesh — register project work, report progress, and warm ~/.hq/work-mesh/cache");
|
|
134
|
+
addSharedFlags(mesh
|
|
135
|
+
.command("check")
|
|
136
|
+
.alias("status")
|
|
137
|
+
.alias("projects")
|
|
138
|
+
.description("Show active work-mesh threads for a company/project")).action((opts) => wrap(() => runCheck(opts))());
|
|
139
|
+
addSharedFlags(mesh
|
|
140
|
+
.command("start")
|
|
141
|
+
.description("Ensure a project thread exists and claim/report start")
|
|
142
|
+
.option("--summary <text>", "What you are starting")).action((opts) => wrap(() => runEvent("start", opts))());
|
|
143
|
+
addSharedFlags(mesh
|
|
144
|
+
.command("progress")
|
|
145
|
+
.description("Append a progress event to the project thread")
|
|
146
|
+
.requiredOption("--summary <text>", "Progress summary")).action((opts) => wrap(() => runEvent("progress", opts))());
|
|
147
|
+
addSharedFlags(mesh
|
|
148
|
+
.command("blocked")
|
|
149
|
+
.description("Append a blocked event to the project thread")
|
|
150
|
+
.option("--reason <text>", "Why you are blocked")
|
|
151
|
+
.option("--ask <text>", "Repeatable ask", collectRepeatable)
|
|
152
|
+
.option("--summary <text>", "Optional summary")).action((opts) => wrap(() => runEvent("blocked", opts))());
|
|
153
|
+
addSharedFlags(mesh
|
|
154
|
+
.command("done")
|
|
155
|
+
.description("Mark the project thread done")
|
|
156
|
+
.option("--summary <text>", "What shipped")).action((opts) => wrap(() => runEvent("done", opts))());
|
|
157
|
+
addSharedFlags(mesh
|
|
158
|
+
.command("note")
|
|
159
|
+
.description("Append a note with no status change")
|
|
160
|
+
.option("--summary <text>", "Note text")).action((opts) => wrap(() => runEvent("note", opts))());
|
|
161
|
+
addSharedFlags(mesh
|
|
162
|
+
.command("story")
|
|
163
|
+
.description("PATCH one Board story status")
|
|
164
|
+
.requiredOption("--story <id>", "Story id, e.g. US-001")
|
|
165
|
+
.requiredOption("--status <status>", "queued|in_progress|review|done")).action((opts) => wrap(() => runStory(opts))());
|
|
166
|
+
mesh
|
|
167
|
+
.command("doctor")
|
|
168
|
+
.description("Warm ~/.hq/work-mesh/cache from hq-pro (directory, inbox, pair DMs). Not `hq doctor`.")
|
|
169
|
+
.option("--apply", "Reserved — Board PUTs from local prd are not in the CLI yet")
|
|
170
|
+
.option("--json", "Print machine-readable JSON")
|
|
171
|
+
.action((opts) => wrap(() => runDoctor(opts))());
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=mesh.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native work-mesh client for `hq mesh`.
|
|
3
|
+
*
|
|
4
|
+
* Talks to hq-pro over the shared vault fetch + Cognito session. Writes the
|
|
5
|
+
* machine cache apps already read. Does not spawn the pack helper and does
|
|
6
|
+
* not start MQTT listen.
|
|
7
|
+
*/
|
|
8
|
+
export declare const STORY_STATUSES: Set<string>;
|
|
9
|
+
export declare const ACTIVE_STATUSES: Set<string>;
|
|
10
|
+
export interface MeshCompany {
|
|
11
|
+
companyUid: string;
|
|
12
|
+
companySlug: string;
|
|
13
|
+
}
|
|
14
|
+
export interface MeshThread {
|
|
15
|
+
threadId?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
companyUid?: string;
|
|
18
|
+
threadStatus?: string;
|
|
19
|
+
ownerUid?: string;
|
|
20
|
+
progressSummary?: string;
|
|
21
|
+
sourceSignalSummary?: string;
|
|
22
|
+
blockedReason?: string;
|
|
23
|
+
lastActivityAt?: string;
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function meshJson(token: string, path: string, init?: {
|
|
27
|
+
method?: string;
|
|
28
|
+
body?: Record<string, unknown>;
|
|
29
|
+
query?: Record<string, string>;
|
|
30
|
+
}): Promise<unknown>;
|
|
31
|
+
export declare function resolveMeshCompany(token: string, company?: string): Promise<MeshCompany>;
|
|
32
|
+
export declare function requireToken(): Promise<string>;
|
|
33
|
+
export declare function listActiveThreads(token: string, companyUid: string, projectId?: string): Promise<MeshThread[]>;
|
|
34
|
+
export declare function clamp(value: string, max: number): string;
|
|
35
|
+
export declare function eventPayload(kind: string, opts: {
|
|
36
|
+
summary?: string;
|
|
37
|
+
reason?: string;
|
|
38
|
+
ask?: string[];
|
|
39
|
+
}, claimedBy: string): Record<string, unknown>;
|
|
40
|
+
export declare function ensureProjectThread(token: string, company: MeshCompany, projectId: string, opts: {
|
|
41
|
+
threadId?: string;
|
|
42
|
+
summary?: string;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
threadId: string;
|
|
45
|
+
created: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
export declare function appendThreadEvent(token: string, companyUid: string, threadId: string, eventKind: string, payload: Record<string, unknown>): Promise<{
|
|
48
|
+
eventId?: string;
|
|
49
|
+
createdAt?: string;
|
|
50
|
+
}>;
|
|
51
|
+
export declare function patchStoryStatus(token: string, companyUid: string, projectId: string, storyId: string, status: string): Promise<{
|
|
52
|
+
status: string;
|
|
53
|
+
version?: number;
|
|
54
|
+
projectId: string;
|
|
55
|
+
}>;
|
|
56
|
+
export declare function pairDmUidsFromInbox(inbox: unknown): string[];
|
|
57
|
+
export interface WarmCacheResult {
|
|
58
|
+
directory: boolean;
|
|
59
|
+
inbox: boolean;
|
|
60
|
+
contacts: boolean;
|
|
61
|
+
threads: number;
|
|
62
|
+
cacheRoot: string;
|
|
63
|
+
}
|
|
64
|
+
/** Warm inbox + contacts + pair threads + directory into the local cache. */
|
|
65
|
+
export declare function warmMeshConversationCache(token: string, principalUid: string, cacheRoot?: string): Promise<WarmCacheResult>;
|
|
66
|
+
export declare function entityUidFromJwt(token: string): string | undefined;
|
|
67
|
+
export declare function principalUidFromTopic(topic: unknown): string | undefined;
|
|
68
|
+
/** Extract prs_/agt_ from a realtime credentials payload. Never returns secrets. */
|
|
69
|
+
export declare function principalUidFromRealtimePayload(data: unknown): string | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Cache key for directory/inbox/contacts. `custom:entityUid` rides the ID
|
|
72
|
+
* token (access tokens only have email/sub), so prefer the cached idToken.
|
|
73
|
+
*/
|
|
74
|
+
export declare function meshCachePrincipalUid(tokens: {
|
|
75
|
+
idToken?: string;
|
|
76
|
+
accessToken?: string;
|
|
77
|
+
} | null | undefined, fallbackToken?: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the cache principal. JWT first; if the human access/id tokens have
|
|
80
|
+
* no entityUid, ask `/v1/realtime/credentials` (same fallback as the pack
|
|
81
|
+
* doctor). Only the uid is kept — credentials are discarded.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveMeshPrincipalUid(token: string, tokens?: {
|
|
84
|
+
idToken?: string;
|
|
85
|
+
accessToken?: string;
|
|
86
|
+
} | null): Promise<string>;
|
|
87
|
+
export declare function callerLabelFromToken(token: string): string;
|
|
88
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native work-mesh client for `hq mesh`.
|
|
3
|
+
*
|
|
4
|
+
* Talks to hq-pro over the shared vault fetch + Cognito session. Writes the
|
|
5
|
+
* machine cache apps already read. Does not spawn the pack helper and does
|
|
6
|
+
* not start MQTT listen.
|
|
7
|
+
*/
|
|
8
|
+
import { ensureCognitoToken } from "../../utils/cognito-session.js";
|
|
9
|
+
import { peekIdToken } from "../../utils/id-token.js";
|
|
10
|
+
import { getCompanyUid, vaultApiFetch } from "../../utils/vault-api.js";
|
|
11
|
+
import { meshCacheRoot, removeSessionFallbackFiles, writeMeshCacheFile, } from "./cache.js";
|
|
12
|
+
const PRINCIPAL_UID = /^(prs|agt)_/;
|
|
13
|
+
export const STORY_STATUSES = new Set([
|
|
14
|
+
"queued",
|
|
15
|
+
"in_progress",
|
|
16
|
+
"review",
|
|
17
|
+
"done",
|
|
18
|
+
]);
|
|
19
|
+
export const ACTIVE_STATUSES = new Set([
|
|
20
|
+
"open",
|
|
21
|
+
"claimed",
|
|
22
|
+
"in-progress",
|
|
23
|
+
"blocked",
|
|
24
|
+
"needs-human",
|
|
25
|
+
]);
|
|
26
|
+
export async function meshJson(token, path, init = {}) {
|
|
27
|
+
const res = await vaultApiFetch({
|
|
28
|
+
token,
|
|
29
|
+
path,
|
|
30
|
+
method: init.method,
|
|
31
|
+
body: init.body,
|
|
32
|
+
query: init.query,
|
|
33
|
+
});
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
let data = {};
|
|
36
|
+
if (text) {
|
|
37
|
+
try {
|
|
38
|
+
data = JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
data = { raw: text };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const err = data;
|
|
46
|
+
throw new Error(err.error || err.message || `${res.status} ${res.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
50
|
+
export async function resolveMeshCompany(token, company) {
|
|
51
|
+
const explicit = process.env.HQ_WORK_MESH_COMPANY_UID || process.env.HQ_COMPANY_UID;
|
|
52
|
+
if (explicit?.trim()) {
|
|
53
|
+
return { companyUid: explicit.trim(), companySlug: company || explicit.trim() };
|
|
54
|
+
}
|
|
55
|
+
if (company && /^(cmp|co)_/.test(company)) {
|
|
56
|
+
return { companyUid: company, companySlug: company };
|
|
57
|
+
}
|
|
58
|
+
const uid = await getCompanyUid(token, company);
|
|
59
|
+
return { companyUid: uid, companySlug: company || uid };
|
|
60
|
+
}
|
|
61
|
+
export async function requireToken() {
|
|
62
|
+
return ensureCognitoToken();
|
|
63
|
+
}
|
|
64
|
+
function asRecord(value) {
|
|
65
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
66
|
+
? value
|
|
67
|
+
: null;
|
|
68
|
+
}
|
|
69
|
+
export async function listActiveThreads(token, companyUid, projectId) {
|
|
70
|
+
const collected = [];
|
|
71
|
+
for (const status of ACTIVE_STATUSES) {
|
|
72
|
+
let cursor = "";
|
|
73
|
+
for (let page = 0; page < 10; page += 1) {
|
|
74
|
+
const query = {
|
|
75
|
+
companyUid,
|
|
76
|
+
status,
|
|
77
|
+
limit: "100",
|
|
78
|
+
};
|
|
79
|
+
if (cursor)
|
|
80
|
+
query.cursor = cursor;
|
|
81
|
+
const data = asRecord(await meshJson(token, "/v1/work-mesh/threads", { query }));
|
|
82
|
+
const rows = Array.isArray(data?.threads)
|
|
83
|
+
? data.threads
|
|
84
|
+
: Array.isArray(data?.items)
|
|
85
|
+
? data.items
|
|
86
|
+
: [];
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
const rec = asRecord(row);
|
|
89
|
+
if (!rec)
|
|
90
|
+
continue;
|
|
91
|
+
collected.push(rec);
|
|
92
|
+
}
|
|
93
|
+
cursor = typeof data?.nextCursor === "string" ? data.nextCursor : "";
|
|
94
|
+
if (!cursor)
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return collected
|
|
99
|
+
.filter((thread) => !projectId || thread.projectId === projectId)
|
|
100
|
+
.sort((a, b) => String(b.lastActivityAt || b.createdAt || "").localeCompare(String(a.lastActivityAt || a.createdAt || "")));
|
|
101
|
+
}
|
|
102
|
+
export function clamp(value, max) {
|
|
103
|
+
return value.length <= max ? value : value.slice(0, max);
|
|
104
|
+
}
|
|
105
|
+
export function eventPayload(kind, opts, claimedBy) {
|
|
106
|
+
if (kind === "claim") {
|
|
107
|
+
return {
|
|
108
|
+
claimedBy: clamp(claimedBy, 120),
|
|
109
|
+
leaseTtlIso: new Date(Date.now() + 120 * 60 * 1000).toISOString(),
|
|
110
|
+
note: clamp(opts.summary || "Starting HQ project work.", 280),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (kind === "progress") {
|
|
114
|
+
return { summary: clamp(opts.summary || "Project work is in progress.", 280) };
|
|
115
|
+
}
|
|
116
|
+
if (kind === "blocked") {
|
|
117
|
+
return {
|
|
118
|
+
reason: clamp(opts.reason || opts.summary || "Project work is blocked.", 500),
|
|
119
|
+
asks: (opts.ask ?? []).filter(Boolean).slice(0, 5),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (kind === "done") {
|
|
123
|
+
return { summary: clamp(opts.summary || "Project work completed.", 280) };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
text: clamp(opts.summary || "HQ project note.", 500),
|
|
127
|
+
noteKind: "audit",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export async function ensureProjectThread(token, company, projectId, opts) {
|
|
131
|
+
if (opts.threadId)
|
|
132
|
+
return { threadId: opts.threadId, created: false };
|
|
133
|
+
const existing = (await listActiveThreads(token, company.companyUid, projectId))[0];
|
|
134
|
+
if (existing?.threadId)
|
|
135
|
+
return { threadId: existing.threadId, created: false };
|
|
136
|
+
const created = asRecord(await meshJson(token, "/v1/work-mesh/threads", {
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: {
|
|
139
|
+
companyUid: company.companyUid,
|
|
140
|
+
projectId,
|
|
141
|
+
sourceSignalSummary: clamp(opts.summary || `HQ project ${projectId}`, 280),
|
|
142
|
+
routing: {
|
|
143
|
+
priority: "normal",
|
|
144
|
+
tags: ["hq-project", `project:${projectId}`],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
148
|
+
const threadId = String(created?.threadId || "").trim();
|
|
149
|
+
if (!threadId)
|
|
150
|
+
throw new Error("Work mesh did not return a threadId");
|
|
151
|
+
return { threadId, created: true };
|
|
152
|
+
}
|
|
153
|
+
export async function appendThreadEvent(token, companyUid, threadId, eventKind, payload) {
|
|
154
|
+
const data = asRecord(await meshJson(token, `/v1/work-mesh/threads/${encodeURIComponent(threadId)}/events`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
body: { companyUid, eventKind, payload },
|
|
157
|
+
}));
|
|
158
|
+
return {
|
|
159
|
+
eventId: typeof data?.eventId === "string" ? data.eventId : undefined,
|
|
160
|
+
createdAt: typeof data?.createdAt === "string" ? data.createdAt : undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export async function patchStoryStatus(token, companyUid, projectId, storyId, status) {
|
|
164
|
+
const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/stories/${encodeURIComponent(storyId)}`, {
|
|
165
|
+
method: "PATCH",
|
|
166
|
+
body: { companyUid, status },
|
|
167
|
+
}));
|
|
168
|
+
if (view && typeof view.projectId === "string" && typeof view.companyUid === "string") {
|
|
169
|
+
writeMeshCacheFile(["projects", view.companyUid, `${view.projectId}.json`], view);
|
|
170
|
+
}
|
|
171
|
+
const stories = Array.isArray(view?.stories) ? view.stories : [];
|
|
172
|
+
const patched = stories
|
|
173
|
+
.map(asRecord)
|
|
174
|
+
.find((row) => row && row.id === storyId);
|
|
175
|
+
return {
|
|
176
|
+
projectId: String(view?.projectId || projectId),
|
|
177
|
+
status: typeof patched?.status === "string" ? patched.status : status,
|
|
178
|
+
version: typeof view?.version === "number" ? view.version : undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export function pairDmUidsFromInbox(inbox) {
|
|
182
|
+
const rec = asRecord(inbox);
|
|
183
|
+
const ids = new Set();
|
|
184
|
+
const events = Array.isArray(rec?.events) ? rec.events : [];
|
|
185
|
+
for (const event of events) {
|
|
186
|
+
const row = asRecord(event);
|
|
187
|
+
const uid = String(row?.fromPersonUid || "").trim();
|
|
188
|
+
if (/^(prs|agt)_[A-Za-z0-9]+$/.test(uid))
|
|
189
|
+
ids.add(uid);
|
|
190
|
+
}
|
|
191
|
+
const pairs = Array.isArray(rec?.pairUnreads) ? rec.pairUnreads : [];
|
|
192
|
+
for (const pair of pairs) {
|
|
193
|
+
const row = asRecord(pair);
|
|
194
|
+
const uid = String(row?.withPersonUid || "").trim();
|
|
195
|
+
if (/^(prs|agt)_[A-Za-z0-9]+$/.test(uid))
|
|
196
|
+
ids.add(uid);
|
|
197
|
+
}
|
|
198
|
+
return [...ids].sort();
|
|
199
|
+
}
|
|
200
|
+
/** Warm inbox + contacts + pair threads + directory into the local cache. */
|
|
201
|
+
export async function warmMeshConversationCache(token, principalUid, cacheRoot = meshCacheRoot()) {
|
|
202
|
+
const result = {
|
|
203
|
+
directory: false,
|
|
204
|
+
inbox: false,
|
|
205
|
+
contacts: false,
|
|
206
|
+
threads: 0,
|
|
207
|
+
cacheRoot,
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
const directory = await meshJson(token, "/v1/notify/channels", {
|
|
211
|
+
query: { cursor: "" },
|
|
212
|
+
});
|
|
213
|
+
writeMeshCacheFile(["directory", `${principalUid}.json`], directory, cacheRoot);
|
|
214
|
+
result.directory = true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Directory warm is best-effort; inbox is the pair-DM path.
|
|
218
|
+
}
|
|
219
|
+
const inbox = await meshJson(token, "/v1/notify/inbox", {
|
|
220
|
+
query: { limit: "50" },
|
|
221
|
+
});
|
|
222
|
+
writeMeshCacheFile(["inbox", `${principalUid}.json`], inbox, cacheRoot);
|
|
223
|
+
result.inbox = true;
|
|
224
|
+
try {
|
|
225
|
+
const contacts = await meshJson(token, "/v1/notify/contacts");
|
|
226
|
+
writeMeshCacheFile(["contacts", `${principalUid}.json`], contacts, cacheRoot);
|
|
227
|
+
result.contacts = true;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Contacts roster is optional for the rail.
|
|
231
|
+
}
|
|
232
|
+
for (const uid of pairDmUidsFromInbox(inbox)) {
|
|
233
|
+
try {
|
|
234
|
+
const thread = await meshJson(token, "/v1/notify/thread", {
|
|
235
|
+
query: { withPersonUid: uid, limit: "50" },
|
|
236
|
+
});
|
|
237
|
+
writeMeshCacheFile(["dms", `${uid}.json`], thread, cacheRoot);
|
|
238
|
+
result.threads += 1;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// one pair must not fail the rest
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (PRINCIPAL_UID.test(principalUid)) {
|
|
245
|
+
removeSessionFallbackFiles(cacheRoot);
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
export function entityUidFromJwt(token) {
|
|
250
|
+
const uid = peekIdToken(token)["custom:entityUid"];
|
|
251
|
+
return typeof uid === "string" && PRINCIPAL_UID.test(uid) ? uid : undefined;
|
|
252
|
+
}
|
|
253
|
+
export function principalUidFromTopic(topic) {
|
|
254
|
+
if (typeof topic !== "string")
|
|
255
|
+
return undefined;
|
|
256
|
+
const match = /^hq\/((?:prs|agt)_[0-9A-HJKMNP-TV-Z]{26})(?:\/|$)/.exec(topic);
|
|
257
|
+
return match?.[1];
|
|
258
|
+
}
|
|
259
|
+
/** Extract prs_/agt_ from a realtime credentials payload. Never returns secrets. */
|
|
260
|
+
export function principalUidFromRealtimePayload(data) {
|
|
261
|
+
const rec = asRecord(data);
|
|
262
|
+
if (!rec)
|
|
263
|
+
return undefined;
|
|
264
|
+
const claimed = rec.principalUid;
|
|
265
|
+
if (typeof claimed === "string" && PRINCIPAL_UID.test(claimed))
|
|
266
|
+
return claimed;
|
|
267
|
+
const topics = asRecord(rec.topics);
|
|
268
|
+
return (principalUidFromTopic(topics?.dm) ||
|
|
269
|
+
principalUidFromTopic(rec.topic));
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Cache key for directory/inbox/contacts. `custom:entityUid` rides the ID
|
|
273
|
+
* token (access tokens only have email/sub), so prefer the cached idToken.
|
|
274
|
+
*/
|
|
275
|
+
export function meshCachePrincipalUid(tokens, fallbackToken) {
|
|
276
|
+
for (const token of [tokens?.idToken, tokens?.accessToken, fallbackToken]) {
|
|
277
|
+
if (!token)
|
|
278
|
+
continue;
|
|
279
|
+
const uid = entityUidFromJwt(token);
|
|
280
|
+
if (uid)
|
|
281
|
+
return uid;
|
|
282
|
+
}
|
|
283
|
+
return "session";
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Resolve the cache principal. JWT first; if the human access/id tokens have
|
|
287
|
+
* no entityUid, ask `/v1/realtime/credentials` (same fallback as the pack
|
|
288
|
+
* doctor). Only the uid is kept — credentials are discarded.
|
|
289
|
+
*/
|
|
290
|
+
export async function resolveMeshPrincipalUid(token, tokens) {
|
|
291
|
+
const fromJwt = meshCachePrincipalUid(tokens, token);
|
|
292
|
+
if (fromJwt !== "session")
|
|
293
|
+
return fromJwt;
|
|
294
|
+
try {
|
|
295
|
+
const data = await meshJson(token, "/v1/realtime/credentials", {
|
|
296
|
+
method: "POST",
|
|
297
|
+
body: { contractVersion: 2 },
|
|
298
|
+
});
|
|
299
|
+
const uid = principalUidFromRealtimePayload(data);
|
|
300
|
+
if (uid)
|
|
301
|
+
return uid;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// best-effort; session.json is worse than failing doctor
|
|
305
|
+
}
|
|
306
|
+
return "session";
|
|
307
|
+
}
|
|
308
|
+
export function callerLabelFromToken(token) {
|
|
309
|
+
const claims = peekIdToken(token);
|
|
310
|
+
const entityUid = claims["custom:entityUid"];
|
|
311
|
+
if (typeof entityUid === "string" && entityUid)
|
|
312
|
+
return entityUid;
|
|
313
|
+
if (typeof claims.email === "string" && claims.email)
|
|
314
|
+
return claims.email;
|
|
315
|
+
if (typeof claims.sub === "string" && claims.sub)
|
|
316
|
+
return claims.sub;
|
|
317
|
+
return "hq-cli";
|
|
318
|
+
}
|
|
319
|
+
//# sourceMappingURL=api.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk work-mesh cache. Same layout the desktop/web apps and the pack
|
|
3
|
+
* helper read: ~/.hq/work-mesh/cache/{projects,channels,directory,inbox,contacts,dms}.
|
|
4
|
+
*/
|
|
5
|
+
export declare function meshCacheRoot(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
6
|
+
export declare function isSafeCacheSegment(value: string): boolean;
|
|
7
|
+
export declare function writeMeshCacheFile(segments: string[], value: unknown, root?: string): string | null;
|
|
8
|
+
/** Drop leftover session.json once we know the real prs_/agt_ cache key. */
|
|
9
|
+
export declare function removeSessionFallbackFiles(root?: string): void;
|
|
10
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk work-mesh cache. Same layout the desktop/web apps and the pack
|
|
3
|
+
* helper read: ~/.hq/work-mesh/cache/{projects,channels,directory,inbox,contacts,dms}.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
export function meshCacheRoot(home = os.homedir(), env = process.env) {
|
|
9
|
+
const override = env.HQ_WORK_MESH_CACHE?.trim();
|
|
10
|
+
if (override)
|
|
11
|
+
return override;
|
|
12
|
+
return path.join(home, ".hq", "work-mesh", "cache");
|
|
13
|
+
}
|
|
14
|
+
export function isSafeCacheSegment(value) {
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
return Boolean(trimmed &&
|
|
17
|
+
trimmed.length <= 128 &&
|
|
18
|
+
!trimmed.includes("..") &&
|
|
19
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed));
|
|
20
|
+
}
|
|
21
|
+
export function writeMeshCacheFile(segments, value, root = meshCacheRoot()) {
|
|
22
|
+
if (segments.length === 0 || !segments.every(isSafeCacheSegment))
|
|
23
|
+
return null;
|
|
24
|
+
const dest = path.join(root, ...segments);
|
|
25
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
26
|
+
const tmp = `${dest}.tmp`;
|
|
27
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
28
|
+
fs.renameSync(tmp, dest);
|
|
29
|
+
return dest;
|
|
30
|
+
}
|
|
31
|
+
/** Drop leftover session.json once we know the real prs_/agt_ cache key. */
|
|
32
|
+
export function removeSessionFallbackFiles(root = meshCacheRoot()) {
|
|
33
|
+
for (const dir of ["directory", "inbox", "contacts"]) {
|
|
34
|
+
try {
|
|
35
|
+
fs.unlinkSync(path.join(root, dir, "session.json"));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// missing is fine
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=cache.js.map
|
package/dist/main.js
CHANGED
|
@@ -61,10 +61,12 @@ import { registerCoreCommands } from "./commands/core.js";
|
|
|
61
61
|
import { registerSearchCommand } from "./commands/search.js";
|
|
62
62
|
import { registerIndexCommand } from "./commands/index-cmd.js";
|
|
63
63
|
import { registerDoctorCommand } from "./commands/doctor.js";
|
|
64
|
+
import { registerMeshCommand } from "./commands/mesh.js";
|
|
64
65
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
65
66
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
66
67
|
import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
|
|
67
68
|
import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
|
|
69
|
+
import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
|
|
68
70
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
69
71
|
import { isEpipe } from "./utils/epipe.js";
|
|
70
72
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
@@ -273,6 +275,9 @@ registerIndexCommand(program);
|
|
|
273
275
|
// check registry so later check families (vault, sync, MCP, …) plug in without
|
|
274
276
|
// engine changes.
|
|
275
277
|
registerDoctorCommand(program);
|
|
278
|
+
// Work mesh (subcommand group — `hq mesh …`). Native REST + cache. Distinct
|
|
279
|
+
// from `hq doctor` (hook guardrails). Does not start MQTT listen.
|
|
280
|
+
registerMeshCommand(program);
|
|
276
281
|
program.hook("preAction", async () => {
|
|
277
282
|
await emitCliSessionStarted();
|
|
278
283
|
});
|
|
@@ -438,7 +443,21 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
438
443
|
// Ordered FIRST so the narrow native-binding signature wins over the
|
|
439
444
|
// broader environmental / transport / generic branches.
|
|
440
445
|
const qmdMsg = qmdNativeBindingErrorMessage(err);
|
|
441
|
-
|
|
446
|
+
// A `hq search <query> -c <collection>` against a collection that is not
|
|
447
|
+
// indexed on THIS machine is the user's local index state, not an hq-cli
|
|
448
|
+
// defect — the remedy is an explicit `hq index sync`. finishRunQmd already
|
|
449
|
+
// types the condition QmdCollectionMissingError, but nothing caught it, so
|
|
450
|
+
// it fell through to the capture below; because the synthesized message
|
|
451
|
+
// embeds the caller's whole argv, each distinct query minted a brand-new
|
|
452
|
+
// permanent issue. Print an actionable line naming the missing collection
|
|
453
|
+
// and skip capture (HQ-CLI-S, Sentry 7672722729). Evaluated AFTER the
|
|
454
|
+
// native-binding check so that narrower signature keeps priority, and
|
|
455
|
+
// BEFORE the environmental / transport / generic branches. Scoped to
|
|
456
|
+
// caller-supplied reads (search/vsearch/query/get); a collection-missing
|
|
457
|
+
// raised by hq's OWN reconciliation (`collection add`/`context add`) names
|
|
458
|
+
// a collection hq built itself, so it stays a captured internal error.
|
|
459
|
+
const collectionMsg = qmdMsg ? null : qmdMissingCollectionMessage(err);
|
|
460
|
+
const envMsg = qmdMsg || collectionMsg ? null : environmentalFsErrorMessage(err);
|
|
442
461
|
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
443
462
|
// with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
|
|
444
463
|
// caller's connectivity, not an hq-cli defect. Before this branch it fell
|
|
@@ -449,10 +468,13 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
449
468
|
// message that names the unreachable host, exit 1, and skip Sentry.
|
|
450
469
|
// Ordered after the environmental check so a full disk keeps its exact
|
|
451
470
|
// existing message.
|
|
452
|
-
const transportMsg = qmdMsg || envMsg ? null : networkTransportErrorMessage(err);
|
|
471
|
+
const transportMsg = qmdMsg || collectionMsg || envMsg ? null : networkTransportErrorMessage(err);
|
|
453
472
|
if (qmdMsg) {
|
|
454
473
|
deps.stderr.write(`hq: ${qmdMsg}\n`);
|
|
455
474
|
}
|
|
475
|
+
else if (collectionMsg) {
|
|
476
|
+
deps.stderr.write(`hq: ${collectionMsg}\n`);
|
|
477
|
+
}
|
|
456
478
|
else if (envMsg) {
|
|
457
479
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
458
480
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If `err` is a caller-driven "collection not found" from `hq search` (or its
|
|
3
|
+
* `get` sibling), return an actionable, query-free remedy naming the missing
|
|
4
|
+
* collection; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
|
|
5
|
+
* environmentalFsErrorMessage / networkTransportErrorMessage so the top-level
|
|
6
|
+
* handler can branch on it the same way: a non-null result means
|
|
7
|
+
* print-and-skip-Sentry, null means "handle as usual (capture to Sentry)".
|
|
8
|
+
*/
|
|
9
|
+
export declare function qmdMissingCollectionMessage(err: unknown): string | null;
|
|
10
|
+
//# sourceMappingURL=qmd-collection-missing-error.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/utils/qmd-collection-missing-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify a "collection not found" qmd failure that came from a caller-supplied
|
|
4
|
+
// READ — `hq search`, its `--mode` siblings (vsearch/query), or `hq search get`
|
|
5
|
+
// — as the user's LOCAL index state, not an hq-cli code defect. The collection
|
|
6
|
+
// the caller named is simply not indexed on this machine; the remedy is an
|
|
7
|
+
// explicit `hq index sync`, so the CLI prints an actionable line and SKIPS
|
|
8
|
+
// Sentry capture. Sibling of qmd-native-binding-error.ts (HQ-CLI-J, unbuilt
|
|
9
|
+
// bindings), environmental-error.ts (HQ-CLI-2, full disk), and
|
|
10
|
+
// network-transport-error.ts (HQ-CLI-G, connectivity): a failure that is NOT an
|
|
11
|
+
// hq-cli defect is printed with an actionable message and never filed as a crash.
|
|
12
|
+
//
|
|
13
|
+
// HQ-CLI-S (Sentry 7672722729): `hq search <query> -c <collection>` against a
|
|
14
|
+
// collection that isn't indexed locally made qmd exit 1 with `Collection not
|
|
15
|
+
// found: <collection>`. finishRunQmd already classifies that wording into the
|
|
16
|
+
// dedicated QmdCollectionMissingError subclass, but nothing caught it, so it
|
|
17
|
+
// unwound to the top-level handler's final else and was captured. Worse, the
|
|
18
|
+
// synthesized message interpolates the caller's whole argv, so each distinct
|
|
19
|
+
// query minted a brand-new permanent issue. This classifier closes that boundary.
|
|
20
|
+
//
|
|
21
|
+
// The gate is deliberately narrow on TWO axes so it can neither be tripped by
|
|
22
|
+
// user input nor silence a real bug:
|
|
23
|
+
// 1. CLASS: only a QmdCollectionMissingError (the typed subclass finishRunQmd
|
|
24
|
+
// raises for the collection-not-found wording) qualifies — never a plain
|
|
25
|
+
// QmdExitError, a native-binding failure, or any other error.
|
|
26
|
+
// 2. INVOCATION: only qmd's caller-supplied-collection READS — `search`,
|
|
27
|
+
// `vsearch`, `query`, `get` — qualify. A collection-missing raised by hq's
|
|
28
|
+
// OWN reconciliation (`collection add`/`collection list`, `context add`)
|
|
29
|
+
// names a collection hq constructed itself, so a not-found there is an
|
|
30
|
+
// internal inconsistency worth reporting: it must keep crashing and
|
|
31
|
+
// capturing, so the classifier returns null for those invocations.
|
|
32
|
+
//
|
|
33
|
+
// The collection name is read STRUCTURALLY from the error's own `args` (the
|
|
34
|
+
// token after `-c`/`--collection`), never by parsing qmd's stderr, and the
|
|
35
|
+
// caller's free-text query (args[1]) is never echoed — the same unbounded-
|
|
36
|
+
// fingerprint discipline that motivated the fix. The name is itself a
|
|
37
|
+
// user-supplied `-c` value, so before it is interpolated it is passed through
|
|
38
|
+
// redactErrorText — the same credential-redaction + control-character-flattening
|
|
39
|
+
// + length-bounding chain the generic error path uses — so a newline, terminal
|
|
40
|
+
// escape sequence, credential-like token, or oversized value can neither forge a
|
|
41
|
+
// second `hq:` line nor leak; if nothing survives redaction the message falls
|
|
42
|
+
// back to the name-free variant.
|
|
43
|
+
import { redactErrorText } from "./redact-error-text.js";
|
|
44
|
+
/** qmd subcommands whose collection is chosen by the CALLER (a read surface). */
|
|
45
|
+
const CALLER_COLLECTION_READS = new Set(["search", "vsearch", "query", "get"]);
|
|
46
|
+
/**
|
|
47
|
+
* The two-pronged, factual remedy. `hq index sync` builds the collections HQ can
|
|
48
|
+
* derive from this local root; `hq index collections` shows what is actually
|
|
49
|
+
* registered (covering a name that maps to a company not present here, which
|
|
50
|
+
* sync will never create). Neither claims the named collection will appear.
|
|
51
|
+
*/
|
|
52
|
+
function remedyMessage(collection) {
|
|
53
|
+
return collection
|
|
54
|
+
? `No local search collection named '${collection}'. Run 'hq index sync' to build it, or 'hq index collections' to see what is registered.`
|
|
55
|
+
: "No local search collection was found for that query. Run 'hq index sync' to build the local index, or 'hq index collections' to see what is registered.";
|
|
56
|
+
}
|
|
57
|
+
/** The collection name the caller passed after `-c`/`--collection`, or null. */
|
|
58
|
+
function collectionFromArgs(args) {
|
|
59
|
+
for (let i = 0; i < args.length - 1; i += 1) {
|
|
60
|
+
if (args[i] === "-c" || args[i] === "--collection") {
|
|
61
|
+
const value = args[i + 1];
|
|
62
|
+
if (typeof value === "string" && value.length > 0)
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* If `err` is a caller-driven "collection not found" from `hq search` (or its
|
|
70
|
+
* `get` sibling), return an actionable, query-free remedy naming the missing
|
|
71
|
+
* collection; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
|
|
72
|
+
* environmentalFsErrorMessage / networkTransportErrorMessage so the top-level
|
|
73
|
+
* handler can branch on it the same way: a non-null result means
|
|
74
|
+
* print-and-skip-Sentry, null means "handle as usual (capture to Sentry)".
|
|
75
|
+
*/
|
|
76
|
+
export function qmdMissingCollectionMessage(err) {
|
|
77
|
+
if (err === null || typeof err !== "object")
|
|
78
|
+
return null;
|
|
79
|
+
const record = err;
|
|
80
|
+
if (record.name !== "QmdCollectionMissingError")
|
|
81
|
+
return null;
|
|
82
|
+
const args = Array.isArray(record.args) ? record.args : [];
|
|
83
|
+
const subcommand = args[0];
|
|
84
|
+
if (typeof subcommand !== "string" || !CALLER_COLLECTION_READS.has(subcommand)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const rawCollection = collectionFromArgs(args);
|
|
88
|
+
// The name is a user-supplied `-c` value: redact + flatten + bound it before
|
|
89
|
+
// printing so it cannot forge a second `hq:` line, emit terminal escapes, or
|
|
90
|
+
// leak a credential-shaped token. redactErrorText returns "" when nothing
|
|
91
|
+
// survives, in which case we fall back to the name-free variant.
|
|
92
|
+
const collection = rawCollection ? redactErrorText(rawCollection) || null : null;
|
|
93
|
+
return remedyMessage(collection);
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=qmd-collection-missing-error.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.101.
|
|
3
|
+
"version": "5.101.6",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
25
25
|
"coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
|
|
26
26
|
"vitest": "vitest",
|
|
27
|
-
"clean": "rm -rf dist"
|
|
27
|
+
"clean": "rm -rf dist",
|
|
28
|
+
"prepare": "husky || true"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
@@ -52,6 +53,8 @@
|
|
|
52
53
|
"@vitest/coverage-v8": "4.1.6",
|
|
53
54
|
"aws-sdk-client-mock": "^4.1.0",
|
|
54
55
|
"eslint": "^10.5.0",
|
|
56
|
+
"husky": "^9.1.7",
|
|
57
|
+
"lint-staged": "^15.5.2",
|
|
55
58
|
"typescript": "^5.7.0",
|
|
56
59
|
"typescript-eslint": "^8.61.1",
|
|
57
60
|
"vitest": "^4.1.2"
|
|
@@ -77,5 +80,8 @@
|
|
|
77
80
|
"onlyBuiltDependencies": [
|
|
78
81
|
"better-sqlite3"
|
|
79
82
|
]
|
|
83
|
+
},
|
|
84
|
+
"lint-staged": {
|
|
85
|
+
"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
|
|
80
86
|
}
|
|
81
87
|
}
|