@indigoai-us/hq-cli 5.137.0 → 5.139.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 +17 -0
- package/dist/command-catalog.generated.d.ts +28 -3
- package/dist/command-catalog.generated.js +36 -3
- package/dist/commands/dm.d.ts +5 -10
- package/dist/commands/dm.js +10 -13
- package/dist/commands/mesh.js +159 -6
- package/dist/commands/report.d.ts +0 -1
- package/dist/commands/report.js +93 -13
- package/dist/lib/mesh/api.d.ts +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.139.0] — 2026-09-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq mesh project ensure <projectId> --company <slug> --stories-file prd.json` registers a missing Board view, merges an existing one without replacing live story status or assignee, then checks the company, project id, story ids, and repo paths. `hq mesh project set --create` writes a view when the GET is a 404.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `hq report` now resolves report ids through local channel context and channel report lists instead of probing every channel with a report GET that can produce avoidable 404s.
|
|
14
|
+
|
|
15
|
+
## [5.138.0] — 2026-09-23
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- `hq dm accept`, `decline`, and `block` now resolve pending requests to the
|
|
20
|
+
server's `personUid` field instead of sending unsupported action body fields.
|
|
21
|
+
|
|
5
22
|
## [5.137.0] — 2026-09-23
|
|
6
23
|
### Added
|
|
7
24
|
|
|
@@ -2333,7 +2333,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2333
2333
|
readonly subcommands: readonly [];
|
|
2334
2334
|
}, {
|
|
2335
2335
|
readonly name: "accept";
|
|
2336
|
-
readonly description: "Accept a pending connection request (by email, personUid, or pairKey). Their held message is then delivered.";
|
|
2336
|
+
readonly description: "Accept a pending connection request (by requester email, personUid, or pairKey from `hq dm requests`). Their held message is then delivered.";
|
|
2337
2337
|
readonly aliases: readonly [];
|
|
2338
2338
|
readonly hidden: false;
|
|
2339
2339
|
readonly usage: "[options] <person>";
|
|
@@ -2346,7 +2346,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2346
2346
|
readonly subcommands: readonly [];
|
|
2347
2347
|
}, {
|
|
2348
2348
|
readonly name: "decline";
|
|
2349
|
-
readonly description: "Decline a pending connection request (by email, personUid, or pairKey).";
|
|
2349
|
+
readonly description: "Decline a pending connection request (by requester email, personUid, or pairKey from `hq dm requests`).";
|
|
2350
2350
|
readonly aliases: readonly [];
|
|
2351
2351
|
readonly hidden: false;
|
|
2352
2352
|
readonly usage: "[options] <person>";
|
|
@@ -2359,7 +2359,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2359
2359
|
readonly subcommands: readonly [];
|
|
2360
2360
|
}, {
|
|
2361
2361
|
readonly name: "block";
|
|
2362
|
-
readonly description: "Block a person so they can't send you further requests (by email, personUid, or pairKey).";
|
|
2362
|
+
readonly description: "Block a person so they can't send you further requests (by requester email, personUid, or pairKey from `hq dm requests`).";
|
|
2363
2363
|
readonly aliases: readonly [];
|
|
2364
2364
|
readonly hidden: false;
|
|
2365
2365
|
readonly usage: "[options] <person>";
|
|
@@ -5708,11 +5708,36 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5708
5708
|
}, {
|
|
5709
5709
|
readonly flags: "--repo <path[@branch]>";
|
|
5710
5710
|
readonly description: "Repository binding (repeatable)";
|
|
5711
|
+
}, {
|
|
5712
|
+
readonly flags: "--create";
|
|
5713
|
+
readonly description: "Create the Board view when the GET is a 404";
|
|
5711
5714
|
}, {
|
|
5712
5715
|
readonly flags: "--json";
|
|
5713
5716
|
readonly description: "Print machine-readable JSON";
|
|
5714
5717
|
}];
|
|
5715
5718
|
readonly subcommands: readonly [];
|
|
5719
|
+
}, {
|
|
5720
|
+
readonly name: "ensure";
|
|
5721
|
+
readonly description: "Idempotently register a PRD on the Board and verify the view";
|
|
5722
|
+
readonly aliases: readonly [];
|
|
5723
|
+
readonly hidden: false;
|
|
5724
|
+
readonly usage: "[options] <projectId>";
|
|
5725
|
+
readonly arguments: readonly [{
|
|
5726
|
+
readonly name: "projectId";
|
|
5727
|
+
readonly required: true;
|
|
5728
|
+
readonly variadic: false;
|
|
5729
|
+
}];
|
|
5730
|
+
readonly options: readonly [{
|
|
5731
|
+
readonly flags: "--company <slug|uid>";
|
|
5732
|
+
readonly description: "Company slug or cloud uid";
|
|
5733
|
+
}, {
|
|
5734
|
+
readonly flags: "--stories-file <path>";
|
|
5735
|
+
readonly description: "PRD JSON file with userStories and metadata.repos";
|
|
5736
|
+
}, {
|
|
5737
|
+
readonly flags: "--json";
|
|
5738
|
+
readonly description: "Print {projectId, threadId, channelId, version, stories}";
|
|
5739
|
+
}];
|
|
5740
|
+
readonly subcommands: readonly [];
|
|
5716
5741
|
}, {
|
|
5717
5742
|
readonly name: "register";
|
|
5718
5743
|
readonly description: "POST project registration to create or bind its project channel and Board";
|
|
@@ -3001,7 +3001,7 @@ export const COMMAND_CATALOG = [
|
|
|
3001
3001
|
},
|
|
3002
3002
|
{
|
|
3003
3003
|
"name": "accept",
|
|
3004
|
-
"description": "Accept a pending connection request (by email, personUid, or pairKey). Their held message is then delivered.",
|
|
3004
|
+
"description": "Accept a pending connection request (by requester email, personUid, or pairKey from `hq dm requests`). Their held message is then delivered.",
|
|
3005
3005
|
"aliases": [],
|
|
3006
3006
|
"hidden": false,
|
|
3007
3007
|
"usage": "[options] <person>",
|
|
@@ -3017,7 +3017,7 @@ export const COMMAND_CATALOG = [
|
|
|
3017
3017
|
},
|
|
3018
3018
|
{
|
|
3019
3019
|
"name": "decline",
|
|
3020
|
-
"description": "Decline a pending connection request (by email, personUid, or pairKey).",
|
|
3020
|
+
"description": "Decline a pending connection request (by requester email, personUid, or pairKey from `hq dm requests`).",
|
|
3021
3021
|
"aliases": [],
|
|
3022
3022
|
"hidden": false,
|
|
3023
3023
|
"usage": "[options] <person>",
|
|
@@ -3033,7 +3033,7 @@ export const COMMAND_CATALOG = [
|
|
|
3033
3033
|
},
|
|
3034
3034
|
{
|
|
3035
3035
|
"name": "block",
|
|
3036
|
-
"description": "Block a person so they can't send you further requests (by email, personUid, or pairKey).",
|
|
3036
|
+
"description": "Block a person so they can't send you further requests (by requester email, personUid, or pairKey from `hq dm requests`).",
|
|
3037
3037
|
"aliases": [],
|
|
3038
3038
|
"hidden": false,
|
|
3039
3039
|
"usage": "[options] <person>",
|
|
@@ -7367,6 +7367,10 @@ export const COMMAND_CATALOG = [
|
|
|
7367
7367
|
"flags": "--repo <path[@branch]>",
|
|
7368
7368
|
"description": "Repository binding (repeatable)"
|
|
7369
7369
|
},
|
|
7370
|
+
{
|
|
7371
|
+
"flags": "--create",
|
|
7372
|
+
"description": "Create the Board view when the GET is a 404"
|
|
7373
|
+
},
|
|
7370
7374
|
{
|
|
7371
7375
|
"flags": "--json",
|
|
7372
7376
|
"description": "Print machine-readable JSON"
|
|
@@ -7374,6 +7378,35 @@ export const COMMAND_CATALOG = [
|
|
|
7374
7378
|
],
|
|
7375
7379
|
"subcommands": []
|
|
7376
7380
|
},
|
|
7381
|
+
{
|
|
7382
|
+
"name": "ensure",
|
|
7383
|
+
"description": "Idempotently register a PRD on the Board and verify the view",
|
|
7384
|
+
"aliases": [],
|
|
7385
|
+
"hidden": false,
|
|
7386
|
+
"usage": "[options] <projectId>",
|
|
7387
|
+
"arguments": [
|
|
7388
|
+
{
|
|
7389
|
+
"name": "projectId",
|
|
7390
|
+
"required": true,
|
|
7391
|
+
"variadic": false
|
|
7392
|
+
}
|
|
7393
|
+
],
|
|
7394
|
+
"options": [
|
|
7395
|
+
{
|
|
7396
|
+
"flags": "--company <slug|uid>",
|
|
7397
|
+
"description": "Company slug or cloud uid"
|
|
7398
|
+
},
|
|
7399
|
+
{
|
|
7400
|
+
"flags": "--stories-file <path>",
|
|
7401
|
+
"description": "PRD JSON file with userStories and metadata.repos"
|
|
7402
|
+
},
|
|
7403
|
+
{
|
|
7404
|
+
"flags": "--json",
|
|
7405
|
+
"description": "Print {projectId, threadId, channelId, version, stories}"
|
|
7406
|
+
}
|
|
7407
|
+
],
|
|
7408
|
+
"subcommands": []
|
|
7409
|
+
},
|
|
7377
7410
|
{
|
|
7378
7411
|
"name": "register",
|
|
7379
7412
|
"description": "POST project registration to create or bind its project channel and Board",
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -175,20 +175,15 @@ export interface ConnectionRequest {
|
|
|
175
175
|
*/
|
|
176
176
|
export declare function matchRequest(requests: ConnectionRequest[], identifier: string): ConnectionRequest | null;
|
|
177
177
|
/**
|
|
178
|
-
* Build the POST body for an accept/decline/block action.
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* `withEmail`). Pure → unit-testable.
|
|
178
|
+
* Build the POST body for an accept/decline/block action. The connections
|
|
179
|
+
* endpoints accept the requester's `personUid`, so resolve friendly email or
|
|
180
|
+
* pairKey input through the pending request before building the body. Pure →
|
|
181
|
+
* unit-testable.
|
|
183
182
|
*
|
|
184
183
|
* Throws Error with a user-facing message when the identifier can't be resolved.
|
|
185
184
|
*/
|
|
186
185
|
export declare function buildConnectionActionBody(identifier: string, matched: ConnectionRequest | null): {
|
|
187
|
-
|
|
188
|
-
} | {
|
|
189
|
-
withPersonUid: string;
|
|
190
|
-
} | {
|
|
191
|
-
withEmail: string;
|
|
186
|
+
personUid: string;
|
|
192
187
|
};
|
|
193
188
|
/** Fetch the caller's channels (GET /v1/notify/channels). */
|
|
194
189
|
export declare function fetchChannels(token: string): Promise<ChannelSummary[]>;
|
package/dist/commands/dm.js
CHANGED
|
@@ -355,25 +355,22 @@ export function matchRequest(requests, identifier) {
|
|
|
355
355
|
return null;
|
|
356
356
|
}
|
|
357
357
|
/**
|
|
358
|
-
* Build the POST body for an accept/decline/block action.
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* `withEmail`). Pure → unit-testable.
|
|
358
|
+
* Build the POST body for an accept/decline/block action. The connections
|
|
359
|
+
* endpoints accept the requester's `personUid`, so resolve friendly email or
|
|
360
|
+
* pairKey input through the pending request before building the body. Pure →
|
|
361
|
+
* unit-testable.
|
|
363
362
|
*
|
|
364
363
|
* Throws Error with a user-facing message when the identifier can't be resolved.
|
|
365
364
|
*/
|
|
366
365
|
export function buildConnectionActionBody(identifier, matched) {
|
|
367
366
|
if (matched) {
|
|
368
|
-
return {
|
|
367
|
+
return { personUid: matched.fromPersonUid };
|
|
369
368
|
}
|
|
370
369
|
const r = identifier.trim();
|
|
371
370
|
const rcpt = detectRecipient(r);
|
|
372
371
|
if (rcpt?.toPersonUid)
|
|
373
|
-
return {
|
|
374
|
-
|
|
375
|
-
return { withEmail: rcpt.toEmail };
|
|
376
|
-
throw new Error(`Could not resolve '${identifier}' to a pending request — pass the requester's email, personUid, or the pairKey from \`hq dm requests\`.`);
|
|
372
|
+
return { personUid: rcpt.toPersonUid };
|
|
373
|
+
throw new Error(`Could not resolve '${identifier}' to a pending request — pass the requester's email or personUid, or a pairKey from \`hq dm requests\`.`);
|
|
377
374
|
}
|
|
378
375
|
const CONNECTION_ACTIONS = {
|
|
379
376
|
accept: { path: "accept", done: "Connection accepted" },
|
|
@@ -1328,19 +1325,19 @@ export function registerDmCommand(program) {
|
|
|
1328
1325
|
});
|
|
1329
1326
|
dm
|
|
1330
1327
|
.command("accept <person>")
|
|
1331
|
-
.description("Accept a pending connection request (by email, personUid, or pairKey). Their held message is then delivered.")
|
|
1328
|
+
.description("Accept a pending connection request (by requester email, personUid, or pairKey from `hq dm requests`). Their held message is then delivered.")
|
|
1332
1329
|
.action(async (person) => {
|
|
1333
1330
|
await runConnectionAction("accept", person);
|
|
1334
1331
|
});
|
|
1335
1332
|
dm
|
|
1336
1333
|
.command("decline <person>")
|
|
1337
|
-
.description("Decline a pending connection request (by email, personUid, or pairKey).")
|
|
1334
|
+
.description("Decline a pending connection request (by requester email, personUid, or pairKey from `hq dm requests`).")
|
|
1338
1335
|
.action(async (person) => {
|
|
1339
1336
|
await runConnectionAction("decline", person);
|
|
1340
1337
|
});
|
|
1341
1338
|
dm
|
|
1342
1339
|
.command("block <person>")
|
|
1343
|
-
.description("Block a person so they can't send you further requests (by email, personUid, or pairKey).")
|
|
1340
|
+
.description("Block a person so they can't send you further requests (by requester email, personUid, or pairKey from `hq dm requests`).")
|
|
1344
1341
|
.action(async (person) => {
|
|
1345
1342
|
await runConnectionAction("block", person);
|
|
1346
1343
|
});
|
package/dist/commands/mesh.js
CHANGED
|
@@ -282,6 +282,33 @@ function parseProjectPlan(raw, source) {
|
|
|
282
282
|
throw err;
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
|
+
function httpStatus(err) {
|
|
286
|
+
if (err && typeof err === "object" && "status" in err && typeof err.status === "number") {
|
|
287
|
+
return err.status;
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
function mergeProjectStories(current, planned) {
|
|
292
|
+
const currentById = new Map(current.map((story) => [story.id, story]));
|
|
293
|
+
const plannedIds = new Set(planned.map((story) => story.id));
|
|
294
|
+
const merged = planned.map((story) => {
|
|
295
|
+
const live = currentById.get(story.id);
|
|
296
|
+
if (!live)
|
|
297
|
+
return story;
|
|
298
|
+
const status = live.status || story.status;
|
|
299
|
+
return {
|
|
300
|
+
...story,
|
|
301
|
+
...(status ? { status } : {}),
|
|
302
|
+
passes: status === "done" ? true : typeof live.passes === "boolean" ? live.passes : story.passes,
|
|
303
|
+
...(live.assigneeUid ? { assigneeUid: live.assigneeUid } : {}),
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
return [...merged, ...current.filter((story) => !plannedIds.has(story.id))];
|
|
307
|
+
}
|
|
308
|
+
function mergeProjectRepos(current, planned) {
|
|
309
|
+
const plannedPaths = new Set(planned.map((repo) => repo.path));
|
|
310
|
+
return [...planned, ...current.filter((repo) => !plannedPaths.has(repo.path))];
|
|
311
|
+
}
|
|
285
312
|
async function runProjectSet(projectId, opts) {
|
|
286
313
|
if (opts.storiesFile && opts.storiesJson) {
|
|
287
314
|
fail("Pass only one of --stories-file or --stories-json.");
|
|
@@ -300,21 +327,45 @@ async function runProjectSet(projectId, opts) {
|
|
|
300
327
|
? opts.repo.map((repo, index) => parseProjectRepoSpec(repo, `--repo[${index}]`))
|
|
301
328
|
: undefined;
|
|
302
329
|
const { token, company } = await withCompany(opts);
|
|
303
|
-
|
|
330
|
+
let current;
|
|
331
|
+
try {
|
|
332
|
+
current = await getProjectView(token, company.companyUid, projectId);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
if (httpStatus(err) === 404 && opts.create) {
|
|
336
|
+
if (!source && !opts.name?.trim()) {
|
|
337
|
+
fail("`--create` needs --name or a stories file when the Board view does not exist.");
|
|
338
|
+
}
|
|
339
|
+
current = undefined;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!current && !opts.create) {
|
|
346
|
+
fail(`Work mesh project ${projectId} has no Board view. Re-run with --create to add one.`);
|
|
347
|
+
}
|
|
348
|
+
if (!current) {
|
|
349
|
+
// Register requires a genesis thread and a project view. Create the thread
|
|
350
|
+
// before the PUT; register runs after the view exists.
|
|
351
|
+
await ensureProjectThread(token, company, projectId, {
|
|
352
|
+
summary: opts.name?.trim() || source?.name || projectId,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
304
355
|
const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
|
|
305
356
|
const PROJECT_CHANGED = "The Board changed while you were editing. Re-run to pick up the latest.";
|
|
306
357
|
let view;
|
|
307
358
|
try {
|
|
308
359
|
view = await putProjectView(token, company.companyUid, projectId, {
|
|
309
|
-
name: opts.name?.trim() || source?.name || current
|
|
360
|
+
name: opts.name?.trim() || source?.name || current?.name || projectId,
|
|
310
361
|
...(opts.description !== undefined
|
|
311
362
|
? { description: opts.description }
|
|
312
363
|
: source?.description !== undefined
|
|
313
364
|
? { description: source.description }
|
|
314
|
-
: { description: current
|
|
315
|
-
stories: source?.stories ?? current
|
|
316
|
-
repos: explicitRepos ?? source?.repos ?? current
|
|
317
|
-
...(current
|
|
365
|
+
: { description: current?.description ?? "" }),
|
|
366
|
+
stories: source?.stories ?? current?.stories ?? [],
|
|
367
|
+
repos: explicitRepos ?? source?.repos ?? current?.repos ?? [],
|
|
368
|
+
...(current?.version !== undefined ? { expectedVersion: current.version } : {}),
|
|
318
369
|
});
|
|
319
370
|
}
|
|
320
371
|
catch (err) {
|
|
@@ -327,6 +378,16 @@ async function runProjectSet(projectId, opts) {
|
|
|
327
378
|
}
|
|
328
379
|
throw err;
|
|
329
380
|
}
|
|
381
|
+
if (!current) {
|
|
382
|
+
const registration = await registerMeshProject(token, company.companyUid, projectId);
|
|
383
|
+
if (!registration.threadId || !registration.channelId) {
|
|
384
|
+
fail("Work mesh registration did not return a threadId and channelId.");
|
|
385
|
+
}
|
|
386
|
+
const verified = await getProjectView(token, company.companyUid, projectId);
|
|
387
|
+
if (verified.projectId !== projectId || verified.companyUid !== company.companyUid) {
|
|
388
|
+
fail(`Work mesh project set --create: verified view does not match ${company.companyUid}/${projectId}.`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
330
391
|
if (opts.json) {
|
|
331
392
|
console.log(JSON.stringify({ ok: true, action: "project.set", principalUid, ...view }, null, 2));
|
|
332
393
|
return;
|
|
@@ -347,6 +408,90 @@ async function runProjectRegister(projectId, opts) {
|
|
|
347
408
|
}
|
|
348
409
|
console.log(`Work mesh: project ${registration.projectId} registered (${boardState}; channel ${registration.channelId} ${registration.channelCreated ? "created" : "bound"})`);
|
|
349
410
|
}
|
|
411
|
+
async function runProjectEnsure(projectId, opts) {
|
|
412
|
+
if (!opts.storiesFile?.trim())
|
|
413
|
+
fail("`--stories-file <prd.json>` is required.");
|
|
414
|
+
const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile);
|
|
415
|
+
const { token, company } = await withCompany(opts);
|
|
416
|
+
let current;
|
|
417
|
+
try {
|
|
418
|
+
current = await getProjectView(token, company.companyUid, projectId);
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (httpStatus(err) !== 404)
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
// Genesis thread, then the view, then register. Register returns 403
|
|
425
|
+
// PROJECT_NOT_IN_COMPANY until both exist.
|
|
426
|
+
const genesis = await ensureProjectThread(token, company, projectId, {
|
|
427
|
+
summary: source.name || projectId,
|
|
428
|
+
});
|
|
429
|
+
if (!genesis.threadId)
|
|
430
|
+
fail("Work mesh did not return a threadId.");
|
|
431
|
+
if (!current) {
|
|
432
|
+
await putProjectView(token, company.companyUid, projectId, {
|
|
433
|
+
name: source.name || projectId,
|
|
434
|
+
description: source.description ?? "",
|
|
435
|
+
stories: source.stories,
|
|
436
|
+
repos: source.repos ?? [],
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
const stories = mergeProjectStories(current.stories, source.stories);
|
|
441
|
+
const repos = mergeProjectRepos(current.repos, source.repos ?? []);
|
|
442
|
+
const name = source.name || current.name;
|
|
443
|
+
const description = source.description !== undefined ? source.description : current.description;
|
|
444
|
+
const changed = name !== current.name ||
|
|
445
|
+
description !== current.description ||
|
|
446
|
+
JSON.stringify(stories) !== JSON.stringify(current.stories) ||
|
|
447
|
+
JSON.stringify(repos) !== JSON.stringify(current.repos);
|
|
448
|
+
if (changed) {
|
|
449
|
+
await putProjectView(token, company.companyUid, projectId, {
|
|
450
|
+
name,
|
|
451
|
+
description,
|
|
452
|
+
stories,
|
|
453
|
+
repos,
|
|
454
|
+
...(current.version !== undefined ? { expectedVersion: current.version } : {}),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const registration = await registerMeshProject(token, company.companyUid, projectId);
|
|
459
|
+
if (!registration.threadId || !registration.channelId) {
|
|
460
|
+
fail("Work mesh registration did not return a threadId and channelId.");
|
|
461
|
+
}
|
|
462
|
+
const verified = await getProjectView(token, company.companyUid, projectId);
|
|
463
|
+
if (verified.companyUid !== company.companyUid) {
|
|
464
|
+
fail(`Work mesh project ensure: company mismatch (expected ${company.companyUid}, got ${verified.companyUid}).`);
|
|
465
|
+
}
|
|
466
|
+
if (verified.projectId !== projectId) {
|
|
467
|
+
fail(`Work mesh project ensure: project id mismatch (expected ${projectId}, got ${verified.projectId}).`);
|
|
468
|
+
}
|
|
469
|
+
for (const story of source.stories) {
|
|
470
|
+
if (!verified.stories.some((row) => row.id === story.id)) {
|
|
471
|
+
fail(`Work mesh project ensure: missing story ${story.id}.`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
for (const repo of source.repos ?? []) {
|
|
475
|
+
if (!verified.repos.some((row) => row.path === repo.path)) {
|
|
476
|
+
fail(`Work mesh project ensure: missing repo ${repo.path}.`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (verified.version === undefined) {
|
|
480
|
+
fail("Work mesh project ensure: verified view has no version.");
|
|
481
|
+
}
|
|
482
|
+
const payload = {
|
|
483
|
+
projectId: verified.projectId,
|
|
484
|
+
threadId: registration.threadId,
|
|
485
|
+
channelId: registration.channelId,
|
|
486
|
+
version: verified.version,
|
|
487
|
+
stories: verified.stories,
|
|
488
|
+
};
|
|
489
|
+
if (opts.json) {
|
|
490
|
+
console.log(JSON.stringify(payload));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
console.log(`Work mesh: project ${payload.projectId} ensured (thread ${payload.threadId}, channel ${payload.channelId}, ${payload.stories.length} stories)`);
|
|
494
|
+
}
|
|
350
495
|
async function runDoctor(opts) {
|
|
351
496
|
if (opts.apply) {
|
|
352
497
|
fail("`hq mesh doctor --apply` (paced Board PUTs from local prd.json) is not in the CLI yet.\n" +
|
|
@@ -1347,8 +1492,16 @@ export function registerMeshCommand(program) {
|
|
|
1347
1492
|
.option("--stories-json <json>", "Story array or JSON object with userStories")
|
|
1348
1493
|
.option("--repos-json <json>", "Repository array, such as [{path,branch}]")
|
|
1349
1494
|
.option("--repo <path[@branch]>", "Repository binding (repeatable)", collectRepeatable)
|
|
1495
|
+
.option("--create", "Create the Board view when the GET is a 404")
|
|
1350
1496
|
.option("--json", "Print machine-readable JSON")
|
|
1351
1497
|
.action((projectId, opts) => wrap(() => runProjectSet(projectId, opts))());
|
|
1498
|
+
project
|
|
1499
|
+
.command("ensure <projectId>")
|
|
1500
|
+
.description("Idempotently register a PRD on the Board and verify the view")
|
|
1501
|
+
.requiredOption("--company <slug|uid>", "Company slug or cloud uid")
|
|
1502
|
+
.requiredOption("--stories-file <path>", "PRD JSON file with userStories and metadata.repos")
|
|
1503
|
+
.option("--json", "Print {projectId, threadId, channelId, version, stories}")
|
|
1504
|
+
.action((projectId, opts) => wrap(() => runProjectEnsure(projectId, opts))());
|
|
1352
1505
|
project
|
|
1353
1506
|
.command("register <projectId>")
|
|
1354
1507
|
.description("POST project registration to create or bind its project channel and Board")
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** `hq report` — safe channel-report lifecycle commands for bot runbooks. */
|
|
2
1
|
import { Command } from "commander";
|
|
3
2
|
import { buildReportTarball, deployOrgContext, type ChannelReport, type DeployAccessPolicy, type ReportDeployment, type ReportPatch } from "../lib/report-publish.js";
|
|
4
3
|
/** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
|
package/dist/commands/report.js
CHANGED
|
@@ -1,11 +1,80 @@
|
|
|
1
|
+
/** `hq report` — safe channel-report lifecycle commands for bot runbooks. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
1
5
|
import { ensureCognitoToken, } from "../utils/cognito-session.js";
|
|
2
6
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
3
7
|
import { listReportTemplates } from "../lib/report-templates.js";
|
|
4
8
|
import { adoptReport, buildReportTarball, conventionReportDir, deployOrgContext, ReportPublishError, plainFailureReason, publishReport, } from "../lib/report-publish.js";
|
|
5
9
|
const DEPLOY_API_URL = process.env.HQ_DEPLOY_API_URL ?? "https://api.indigo-hq.com";
|
|
10
|
+
const REPORT_CHANNEL_STATE_FILE = "report-channels.json";
|
|
11
|
+
const MAX_REPORT_DISCOVERY_CHANNELS = 50;
|
|
6
12
|
function reportPath(channelId, reportId) {
|
|
7
13
|
return `/v1/notify/channels/${encodeURIComponent(channelId)}/reports/${encodeURIComponent(reportId)}`;
|
|
8
14
|
}
|
|
15
|
+
function reportListPath(channelId) {
|
|
16
|
+
return `/v1/notify/channels/${encodeURIComponent(channelId)}/reports`;
|
|
17
|
+
}
|
|
18
|
+
function reportChannelStatePath() {
|
|
19
|
+
const stateDir = process.env.HQ_STATE_DIR?.trim() || path.join(os.homedir(), ".hq");
|
|
20
|
+
return path.join(stateDir, REPORT_CHANNEL_STATE_FILE);
|
|
21
|
+
}
|
|
22
|
+
function readReportChannelState() {
|
|
23
|
+
const file = reportChannelStatePath();
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = fs.readFileSync(file, "utf8");
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === "ENOENT") {
|
|
30
|
+
return { version: 1, channels: {} };
|
|
31
|
+
}
|
|
32
|
+
throw new ReportPublishError("Could not read the local report channel state.");
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (parsed.version !== 1 || !parsed.channels || typeof parsed.channels !== "object")
|
|
37
|
+
throw new Error("invalid state");
|
|
38
|
+
const channels = Object.fromEntries(Object.entries(parsed.channels).filter((entry) => typeof entry[1] === "string" && entry[1].length > 0));
|
|
39
|
+
return { version: 1, channels };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new ReportPublishError("Could not read the local report channel state.");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function rememberReportChannel(reportId, channelId) {
|
|
46
|
+
const file = reportChannelStatePath();
|
|
47
|
+
const state = readReportChannelState();
|
|
48
|
+
state.channels[reportId] = channelId;
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
53
|
+
fs.renameSync(temporary, file);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
if (fs.existsSync(temporary))
|
|
57
|
+
fs.rmSync(temporary, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function rememberReportChannelAfterSuccess(report) {
|
|
61
|
+
try {
|
|
62
|
+
rememberReportChannel(report.reportId, report.channelId);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const reason = error instanceof Error ? error.message : "local state write failed";
|
|
66
|
+
console.warn(`Report succeeded but local channel context was not saved: ${reason}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function locallyRecordedChannel(reportId) {
|
|
70
|
+
return readReportChannelState().channels[reportId];
|
|
71
|
+
}
|
|
72
|
+
function notFoundMessage(scannedChannels) {
|
|
73
|
+
const scope = scannedChannels >= MAX_REPORT_DISCOVERY_CHANNELS
|
|
74
|
+
? `the first ${MAX_REPORT_DISCOVERY_CHANNELS} channels`
|
|
75
|
+
: `${scannedChannels} channel${scannedChannels === 1 ? "" : "s"}`;
|
|
76
|
+
return `Report not found. Pass --channel or set HQ_REPORT_CHANNEL_ID. Searched ${scope}.`;
|
|
77
|
+
}
|
|
9
78
|
function errorMessage(error) {
|
|
10
79
|
if (error instanceof ReportPublishError)
|
|
11
80
|
return plainFailureReason(error);
|
|
@@ -29,30 +98,38 @@ export async function resolveReport(token, reportId, requestedChannelId) {
|
|
|
29
98
|
throw new ReportPublishError("Report response was incomplete.");
|
|
30
99
|
return body.report;
|
|
31
100
|
}
|
|
101
|
+
const recordedChannel = locallyRecordedChannel(reportId);
|
|
102
|
+
if (recordedChannel) {
|
|
103
|
+
const res = await vaultApiFetch({ token, path: reportPath(recordedChannel, reportId) });
|
|
104
|
+
if (res.ok) {
|
|
105
|
+
const body = await res.json();
|
|
106
|
+
if (body.report)
|
|
107
|
+
return body.report;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
32
110
|
// The report API is intentionally channel-scoped. Discover only among the
|
|
33
|
-
// caller's existing channels
|
|
111
|
+
// caller's existing channels, using list endpoints so missing reports never
|
|
112
|
+
// become one server-side 404 per channel.
|
|
34
113
|
const channels = await vaultApiFetch({ token, path: "/v1/notify/channels" });
|
|
35
114
|
if (!channels.ok)
|
|
36
115
|
throw new ReportPublishError("Could not resolve the report channel.");
|
|
37
116
|
const listed = await channels.json();
|
|
38
|
-
|
|
117
|
+
let scannedChannels = 0;
|
|
39
118
|
for (const row of listed.channels ?? []) {
|
|
40
119
|
if (!row.channelId)
|
|
41
120
|
continue;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
121
|
+
if (scannedChannels >= MAX_REPORT_DISCOVERY_CHANNELS)
|
|
122
|
+
break;
|
|
123
|
+
scannedChannels += 1;
|
|
124
|
+
const res = await vaultApiFetch({ token, path: reportListPath(row.channelId) });
|
|
45
125
|
if (!res.ok)
|
|
46
126
|
continue;
|
|
47
127
|
const body = await res.json();
|
|
48
|
-
|
|
49
|
-
|
|
128
|
+
const match = body.reports?.find((report) => report.reportId === reportId);
|
|
129
|
+
if (match)
|
|
130
|
+
return match;
|
|
50
131
|
}
|
|
51
|
-
|
|
52
|
-
return matches[0];
|
|
53
|
-
if (matches.length > 1)
|
|
54
|
-
throw new ReportPublishError("Report ID is ambiguous; pass --channel or set HQ_REPORT_CHANNEL_ID.");
|
|
55
|
-
throw new ReportPublishError("Report not found. Pass --channel or set HQ_REPORT_CHANNEL_ID.");
|
|
132
|
+
throw new ReportPublishError(notFoundMessage(scannedChannels));
|
|
56
133
|
}
|
|
57
134
|
export async function patchReport(token, report, patch) {
|
|
58
135
|
const res = await vaultApiFetch({ token, path: reportPath(report.channelId, report.reportId), method: "PATCH", body: patch });
|
|
@@ -146,7 +223,7 @@ export async function deployReportStatic(token, input) {
|
|
|
146
223
|
return { appId: app.id, url, deployId };
|
|
147
224
|
}
|
|
148
225
|
async function publishWithOrg(token, report, directory, draft) {
|
|
149
|
-
|
|
226
|
+
const published = await publishReport(report, directory, draft, {
|
|
150
227
|
patch: (current, patch) => patchReport(token, current, patch),
|
|
151
228
|
deploy: async (input) => {
|
|
152
229
|
const org = await orgFor(token, report);
|
|
@@ -154,6 +231,8 @@ async function publishWithOrg(token, report, directory, draft) {
|
|
|
154
231
|
},
|
|
155
232
|
now: () => new Date().toISOString(),
|
|
156
233
|
});
|
|
234
|
+
rememberReportChannelAfterSuccess(published);
|
|
235
|
+
return published;
|
|
157
236
|
}
|
|
158
237
|
async function publishAction(reportId, options) {
|
|
159
238
|
const token = await ensureCognitoToken();
|
|
@@ -246,6 +325,7 @@ async function adoptAction(reportId, options) {
|
|
|
246
325
|
},
|
|
247
326
|
patch: (current, patch) => patchReport(token, current, patch),
|
|
248
327
|
});
|
|
328
|
+
rememberReportChannelAfterSuccess(updated);
|
|
249
329
|
print(updated, options.json === true);
|
|
250
330
|
}
|
|
251
331
|
async function decommissionAction(reportId, options) {
|
package/dist/lib/mesh/api.d.ts
CHANGED