@indigoai-us/hq-cli 5.345.85 → 5.345.87
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 +30 -0
- package/dist/command-catalog.generated.d.ts +42 -1
- package/dist/command-catalog.generated.js +51 -1
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/bot.d.ts +60 -12
- package/dist/commands/bot.js +349 -98
- package/dist/commands/scan.d.ts +23 -0
- package/dist/commands/scan.js +59 -0
- package/dist/commands/storage-core.d.ts +2 -0
- package/dist/commands/storage-core.js +12 -1
- package/dist/commands/storage-offload.d.ts +10 -0
- package/dist/commands/storage-offload.js +4 -0
- package/dist/commands/storage.d.ts +6 -0
- package/dist/commands/storage.js +25 -6
- package/dist/lib/bot/api.d.ts +15 -0
- package/dist/lib/bot/api.js +11 -0
- package/dist/lib/bot/config.d.ts +6 -0
- package/dist/lib/bot/display-name.d.ts +64 -0
- package/dist/lib/bot/display-name.js +117 -0
- package/dist/lib/bot/index.d.ts +2 -0
- package/dist/lib/bot/index.js +2 -0
- package/dist/lib/bot/lock.d.ts +84 -0
- package/dist/lib/bot/lock.js +313 -0
- package/dist/lib/secret-scan/detect.d.ts +27 -0
- package/dist/lib/secret-scan/detect.js +129 -0
- package/dist/lib/secret-scan/gitleaks-rules.generated.d.ts +6 -0
- package/dist/lib/secret-scan/gitleaks-rules.generated.js +3602 -0
- package/dist/lib/secret-scan/go-regex.d.ts +22 -0
- package/dist/lib/secret-scan/go-regex.js +250 -0
- package/dist/lib/secret-scan/rules.d.ts +31 -0
- package/dist/lib/secret-scan/rules.js +84 -0
- package/dist/lib/secret-scan/staged.d.ts +17 -0
- package/dist/lib/secret-scan/staged.js +112 -0
- package/dist/lib/secret-scan/types.d.ts +34 -0
- package/dist/lib/secret-scan/types.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq scan secrets` — offline secret scanner (the HQ pre-commit gate).
|
|
3
|
+
*
|
|
4
|
+
* hq scan secrets scan the staged changes of the current repo
|
|
5
|
+
* hq scan secrets <file...> scan whole files instead
|
|
6
|
+
* hq scan secrets --json findings as JSON
|
|
7
|
+
*
|
|
8
|
+
* Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
|
|
9
|
+
* HQ's own token patterns. No network, no account, no quota.
|
|
10
|
+
*
|
|
11
|
+
* Exit codes: 0 clean, 1 possible secret found, 2 the scan could not run.
|
|
12
|
+
* A pre-commit hook blocks on any non-zero exit, so the gate fails closed.
|
|
13
|
+
* Output names the rule, file and line; secret values are never printed.
|
|
14
|
+
*/
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
interface ScanSecretsOptions {
|
|
17
|
+
json?: boolean;
|
|
18
|
+
staged?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function runScanSecrets(paths: string[], opts: ScanSecretsOptions, cwd?: string): number;
|
|
21
|
+
export declare function registerScanCommand(program: Command): void;
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=scan.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq scan secrets` — offline secret scanner (the HQ pre-commit gate).
|
|
3
|
+
*
|
|
4
|
+
* hq scan secrets scan the staged changes of the current repo
|
|
5
|
+
* hq scan secrets <file...> scan whole files instead
|
|
6
|
+
* hq scan secrets --json findings as JSON
|
|
7
|
+
*
|
|
8
|
+
* Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
|
|
9
|
+
* HQ's own token patterns. No network, no account, no quota.
|
|
10
|
+
*
|
|
11
|
+
* Exit codes: 0 clean, 1 possible secret found, 2 the scan could not run.
|
|
12
|
+
* A pre-commit hook blocks on any non-zero exit, so the gate fails closed.
|
|
13
|
+
* Output names the rule, file and line; secret values are never printed.
|
|
14
|
+
*/
|
|
15
|
+
import { ScanInputError, scanFiles, scanStaged } from "../lib/secret-scan/staged.js";
|
|
16
|
+
function report(findings, scope) {
|
|
17
|
+
if (!findings.length)
|
|
18
|
+
return;
|
|
19
|
+
const n = findings.length;
|
|
20
|
+
process.stderr.write(`HQ secret scan: ${n} possible secret${n === 1 ? "" : "s"} in ${scope} (values hidden)\n`);
|
|
21
|
+
for (const f of findings) {
|
|
22
|
+
process.stderr.write(` ${f.file}:${f.line} ${f.ruleId} ${f.description}\n`);
|
|
23
|
+
}
|
|
24
|
+
process.stderr.write("\nRemove the secret from the change, then stage it again. Keep real credentials in the HQ vault\n" +
|
|
25
|
+
"(hq secrets, hq run). A known false positive can carry a `gitleaks:allow` comment on that line.\n");
|
|
26
|
+
}
|
|
27
|
+
export function runScanSecrets(paths, opts, cwd = process.cwd()) {
|
|
28
|
+
let findings;
|
|
29
|
+
try {
|
|
30
|
+
findings = paths.length && !opts.staged ? scanFiles(paths) : scanStaged(cwd);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const msg = err instanceof ScanInputError ? err.message : `scan failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
34
|
+
if (opts.json)
|
|
35
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: msg })}\n`);
|
|
36
|
+
process.stderr.write(`HQ secret scan: ${msg}\n`);
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
if (opts.json) {
|
|
40
|
+
process.stdout.write(`${JSON.stringify({ ok: findings.length === 0, findings })}\n`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
report(findings, paths.length && !opts.staged ? "the given files" : "staged changes");
|
|
44
|
+
}
|
|
45
|
+
return findings.length ? 1 : 0;
|
|
46
|
+
}
|
|
47
|
+
export function registerScanCommand(program) {
|
|
48
|
+
const scan = program.command("scan").description("Local, offline scanners");
|
|
49
|
+
scan
|
|
50
|
+
.command("secrets")
|
|
51
|
+
.description("Scan staged changes (or files) for secrets; exits 1 on a finding")
|
|
52
|
+
.argument("[files...]", "Scan these files instead of the staged changes")
|
|
53
|
+
.option("--staged", "Scan the staged changes (default when no files are given)")
|
|
54
|
+
.option("--json", "Print findings as JSON")
|
|
55
|
+
.action((files, opts) => {
|
|
56
|
+
process.exitCode = runScanSecrets(files ?? [], opts);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=scan.js.map
|
|
@@ -51,6 +51,8 @@ export declare function coreHasFlag(script: string, flag: string): boolean;
|
|
|
51
51
|
* ref names are taken from it and the line itself (which tells the user to
|
|
52
52
|
* delete them) is dropped.
|
|
53
53
|
*/
|
|
54
|
+
/** Plain fallback when core's prune refusal carried no message. */
|
|
55
|
+
export declare function pruneNothingToFreeMessage(n: number): string;
|
|
54
56
|
export declare function parseRefusal(stdout: string, stderr: string): LocalReport;
|
|
55
57
|
/** Core exit code for a failed precondition (remote-tracking branches, ...). */
|
|
56
58
|
export declare const CORE_PRECONDITION_EXIT = 5;
|
|
@@ -70,14 +70,22 @@ function normalizeHoldingRefs(raw) {
|
|
|
70
70
|
* ref names are taken from it and the line itself (which tells the user to
|
|
71
71
|
* delete them) is dropped.
|
|
72
72
|
*/
|
|
73
|
+
/** Plain fallback when core's prune refusal carried no message. */
|
|
74
|
+
export function pruneNothingToFreeMessage(n) {
|
|
75
|
+
return (`Pruning would free nothing: ${n} ${n === 1 ? "branch, saved version or worktree still uses" : "branches, saved versions or worktrees still use"} ` +
|
|
76
|
+
"this history. Nothing was changed.");
|
|
77
|
+
}
|
|
73
78
|
export function parseRefusal(stdout, stderr) {
|
|
74
79
|
let refs = [];
|
|
75
80
|
let count;
|
|
81
|
+
let message;
|
|
76
82
|
try {
|
|
77
83
|
const parsed = JSON.parse(stdout);
|
|
78
84
|
refs = normalizeHoldingRefs(parsed.holding_refs);
|
|
79
85
|
if (typeof parsed.holding_refs_count === "number")
|
|
80
86
|
count = parsed.holding_refs_count;
|
|
87
|
+
if (typeof parsed.message === "string" && parsed.message.trim())
|
|
88
|
+
message = parsed.message;
|
|
81
89
|
}
|
|
82
90
|
catch {
|
|
83
91
|
const m = /\(([^()]*refs\/[^()]*)\)/.exec(stderr);
|
|
@@ -88,11 +96,14 @@ export function parseRefusal(stdout, stderr) {
|
|
|
88
96
|
.map((name) => ({ name, commit_date: null }));
|
|
89
97
|
}
|
|
90
98
|
}
|
|
99
|
+
const n = count ?? refs.length;
|
|
91
100
|
return {
|
|
92
101
|
available: true,
|
|
93
102
|
refused: "nothing_to_free",
|
|
103
|
+
// Same shape as offload's refusal: a plain `message` beside the code.
|
|
104
|
+
message: message ?? pruneNothingToFreeMessage(n),
|
|
94
105
|
holding_refs: refs,
|
|
95
|
-
holding_refs_count:
|
|
106
|
+
holding_refs_count: n,
|
|
96
107
|
};
|
|
97
108
|
}
|
|
98
109
|
/** Core exit code for a failed precondition (remote-tracking branches, ...). */
|
|
@@ -261,6 +261,16 @@ export interface OffloadResult {
|
|
|
261
261
|
*/
|
|
262
262
|
refused?: string;
|
|
263
263
|
message?: string;
|
|
264
|
+
/**
|
|
265
|
+
* Dry run only: the refusal a real run would hit, or null when none. A
|
|
266
|
+
* dry run that predicts a refusal still exits 0; only a real run that
|
|
267
|
+
* refuses exits 4 or 5.
|
|
268
|
+
*/
|
|
269
|
+
would_refuse?: {
|
|
270
|
+
code: string;
|
|
271
|
+
message: string;
|
|
272
|
+
holders: string[];
|
|
273
|
+
} | null;
|
|
264
274
|
};
|
|
265
275
|
current: {
|
|
266
276
|
offloaded: {
|
|
@@ -495,6 +495,7 @@ export async function runOffload(opts, deps) {
|
|
|
495
495
|
result.history.message = refusal.message;
|
|
496
496
|
if (!opts.dryRun)
|
|
497
497
|
return result;
|
|
498
|
+
result.history.would_refuse = { code: refusal.refused, message: refusal.message, holders: [] };
|
|
498
499
|
}
|
|
499
500
|
else if (blobs.length === 0 && held.length > 0) {
|
|
500
501
|
// Same contract as prune: nothing to free is a refusal (exit 4), decided
|
|
@@ -503,9 +504,12 @@ export async function runOffload(opts, deps) {
|
|
|
503
504
|
result.history.message = worktreeHoldsAllCopy(held);
|
|
504
505
|
if (!opts.dryRun)
|
|
505
506
|
return result;
|
|
507
|
+
result.history.would_refuse = { code: "nothing_to_free", message: result.history.message, holders: heldWorktrees(held) };
|
|
506
508
|
}
|
|
507
509
|
const noBackup = coreHasFlag(script, "--no-backup");
|
|
508
510
|
if (opts.dryRun) {
|
|
511
|
+
if (result.history.would_refuse === undefined)
|
|
512
|
+
result.history.would_refuse = null;
|
|
509
513
|
// Dry run fills the real-run fields with estimates so a preview reads the
|
|
510
514
|
// same keys: every candidate uploaded, and history shrinks by its size.
|
|
511
515
|
const planned = refusal ? 0 : result.history.candidate_bytes;
|
|
@@ -213,6 +213,12 @@ export declare function holderName(ref: string): string;
|
|
|
213
213
|
export declare function formatHoldingRefusal(local: LocalReport): string[];
|
|
214
214
|
/** Exit code for a prune result: 4 for nothing to free, 5 for a precondition, else 0/1. */
|
|
215
215
|
export declare function pruneExitCode(result: StoragePruneResult): number;
|
|
216
|
+
/**
|
|
217
|
+
* Exit code for an offload result: a real run that refuses exits 4 (nothing
|
|
218
|
+
* to free) or 5 (precondition); a dry run that predicts a refusal exits 0 and
|
|
219
|
+
* reports it in `history.would_refuse`. Errors exit 1.
|
|
220
|
+
*/
|
|
221
|
+
export declare function offloadExitCode(result: OffloadResult): number;
|
|
216
222
|
/** Plain lines for a local prune that was not refused (dry or real). */
|
|
217
223
|
export declare function formatLocalPrune(local: LocalReport, dryRun: boolean): string[];
|
|
218
224
|
export declare function formatStoragePrune(result: StoragePruneResult): string;
|
package/dist/commands/storage.js
CHANGED
|
@@ -743,9 +743,15 @@ export function formatOffload(r) {
|
|
|
743
743
|
const lines = [];
|
|
744
744
|
const h = r.history;
|
|
745
745
|
if (h.refused) {
|
|
746
|
+
// Core's and the CLI's refusal messages may already say nothing changed;
|
|
747
|
+
// add that sentence only when the message lacks it.
|
|
748
|
+
const msg = (h.message ?? "").trim();
|
|
749
|
+
const lead = /^nothing was uploaded or changed\.\s*/i;
|
|
746
750
|
lines.push(r.dry_run
|
|
747
|
-
? `History: a real run would stop before uploading: ${
|
|
748
|
-
:
|
|
751
|
+
? `History: a real run would stop before uploading: ${msg.replace(lead, "")}`.trimEnd()
|
|
752
|
+
: lead.test(msg)
|
|
753
|
+
? `History: ${msg}`
|
|
754
|
+
: `History: nothing was uploaded or changed. ${msg}`.trimEnd());
|
|
749
755
|
}
|
|
750
756
|
else if (h.available === false) {
|
|
751
757
|
lines.push(`History: not available - ${h.reason ?? "unknown"}`);
|
|
@@ -812,6 +818,10 @@ export function formatHoldingRefusal(local) {
|
|
|
812
818
|
/** Exit code for a prune result: 4 for nothing to free, 5 for a precondition, else 0/1. */
|
|
813
819
|
export function pruneExitCode(result) {
|
|
814
820
|
const local = result.local;
|
|
821
|
+
// A dry run that ran is a success even when it predicts a refusal; the
|
|
822
|
+
// JSON reports that in `would_refuse` / `local.refused`.
|
|
823
|
+
if (result.dry_run)
|
|
824
|
+
return result.cloud.some((c) => c.errors.length > 0) ? 1 : 0;
|
|
815
825
|
if (local?.refused === "nothing_to_free")
|
|
816
826
|
return 4;
|
|
817
827
|
if (local && typeof local.refused === "string")
|
|
@@ -820,6 +830,16 @@ export function pruneExitCode(result) {
|
|
|
820
830
|
return 1;
|
|
821
831
|
return 0;
|
|
822
832
|
}
|
|
833
|
+
/**
|
|
834
|
+
* Exit code for an offload result: a real run that refuses exits 4 (nothing
|
|
835
|
+
* to free) or 5 (precondition); a dry run that predicts a refusal exits 0 and
|
|
836
|
+
* reports it in `history.would_refuse`. Errors exit 1.
|
|
837
|
+
*/
|
|
838
|
+
export function offloadExitCode(result) {
|
|
839
|
+
if (result.history.refused && !result.dry_run)
|
|
840
|
+
return result.history.refused === "nothing_to_free" ? 4 : 5;
|
|
841
|
+
return result.errors.length > 0 ? 1 : 0;
|
|
842
|
+
}
|
|
823
843
|
/** Plain lines for a local prune that was not refused (dry or real). */
|
|
824
844
|
export function formatLocalPrune(local, dryRun) {
|
|
825
845
|
const w = local.would_refuse && typeof local.would_refuse === "object" ? local.would_refuse : null;
|
|
@@ -1126,10 +1146,9 @@ export function registerStorageCommand(program) {
|
|
|
1126
1146
|
if (!result)
|
|
1127
1147
|
return;
|
|
1128
1148
|
console.log(opts.json ? JSON.stringify(result, null, 2) : formatOffload(result));
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
process.exitCode = 1;
|
|
1149
|
+
const code = offloadExitCode(result);
|
|
1150
|
+
if (code !== 0)
|
|
1151
|
+
process.exitCode = code;
|
|
1133
1152
|
}
|
|
1134
1153
|
catch (err) {
|
|
1135
1154
|
console.error(chalk.red(err.message));
|
package/dist/lib/bot/api.d.ts
CHANGED
|
@@ -117,6 +117,8 @@ export interface AgentRecord {
|
|
|
117
117
|
at?: string;
|
|
118
118
|
} | null;
|
|
119
119
|
};
|
|
120
|
+
/** "assistant" on HQ's setup assistant, null on other local bots; absent on older servers. */
|
|
121
|
+
agentRole?: string | null;
|
|
120
122
|
}
|
|
121
123
|
/**
|
|
122
124
|
* One of the caller's own local bots, as `GET /v1/agents/mine?local=1` lists
|
|
@@ -133,6 +135,8 @@ export interface MyLocalBot {
|
|
|
133
135
|
online?: boolean;
|
|
134
136
|
lastHeartbeatAt?: string | null;
|
|
135
137
|
localConfig?: unknown;
|
|
138
|
+
/** "assistant" on HQ's setup assistant, null on other local bots; absent on older servers. */
|
|
139
|
+
agentRole?: string | null;
|
|
136
140
|
}
|
|
137
141
|
/** What the credential re-issue route hands back (a rotated machine secret). */
|
|
138
142
|
export interface ReissuedBotCredentials {
|
|
@@ -349,6 +353,17 @@ export declare class BotApi {
|
|
|
349
353
|
listMine(opts?: {
|
|
350
354
|
local?: boolean;
|
|
351
355
|
}): Promise<MyLocalBot[]>;
|
|
356
|
+
/**
|
|
357
|
+
* PUT /v1/agents/{uid}/display-name, owner-only: the one write for a bot's
|
|
358
|
+
* name. The server sets the entity name, the profile display name and the
|
|
359
|
+
* saved local settings' display name together, and answers 200 for the
|
|
360
|
+
* same name twice. A server that predates the route answers 404 or 405;
|
|
361
|
+
* callers decide what that means (see writeCloudDisplayName).
|
|
362
|
+
*/
|
|
363
|
+
setDisplayName(agentUid: string, displayName: string): Promise<{
|
|
364
|
+
agentUid: string;
|
|
365
|
+
displayName: string;
|
|
366
|
+
}>;
|
|
352
367
|
/** DELETE /v1/agents/{uid} — owner tears the identity down. */
|
|
353
368
|
deleteLocalBot(agentUid: string): Promise<{
|
|
354
369
|
uid: string;
|
package/dist/lib/bot/api.js
CHANGED
|
@@ -311,6 +311,17 @@ export class BotApi {
|
|
|
311
311
|
});
|
|
312
312
|
return Array.isArray(body.agents) ? body.agents : [];
|
|
313
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* PUT /v1/agents/{uid}/display-name, owner-only: the one write for a bot's
|
|
316
|
+
* name. The server sets the entity name, the profile display name and the
|
|
317
|
+
* saved local settings' display name together, and answers 200 for the
|
|
318
|
+
* same name twice. A server that predates the route answers 404 or 405;
|
|
319
|
+
* callers decide what that means (see writeCloudDisplayName).
|
|
320
|
+
*/
|
|
321
|
+
async setDisplayName(agentUid, displayName) {
|
|
322
|
+
const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/display-name`, { method: "PUT", body: { displayName } }, [200]);
|
|
323
|
+
return { agentUid: body.agentUid ?? agentUid, displayName: body.displayName ?? displayName };
|
|
324
|
+
}
|
|
314
325
|
/** DELETE /v1/agents/{uid} — owner tears the identity down. */
|
|
315
326
|
async deleteLocalBot(agentUid) {
|
|
316
327
|
const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}`, { method: "DELETE" });
|
package/dist/lib/bot/config.d.ts
CHANGED
|
@@ -124,6 +124,12 @@ export interface BotConfig {
|
|
|
124
124
|
* create; `name` stays the handle every hq bot command uses. Unset = `name`.
|
|
125
125
|
*/
|
|
126
126
|
displayName?: string;
|
|
127
|
+
/**
|
|
128
|
+
* True while `displayName` is newer than HQ's copy: the last rename was
|
|
129
|
+
* kept on this computer because the server had no name route yet. Until a
|
|
130
|
+
* rename reaches HQ, this copy wins over the cloud's older name.
|
|
131
|
+
*/
|
|
132
|
+
displayNameLocalOnly?: boolean;
|
|
127
133
|
/**
|
|
128
134
|
* Verbatim hello the bot DMs its owner when it first comes online (and on
|
|
129
135
|
* `hq bot intro`). Unset = the default introduction.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bot's name and role, as the CLI reports them to the desktop app.
|
|
3
|
+
*
|
|
4
|
+
* The name people see lives in the cloud (entity name, profile display name
|
|
5
|
+
* and the saved local settings, written together by
|
|
6
|
+
* `PUT /v1/agents/{uid}/display-name`). bot.json keeps a copy so the running
|
|
7
|
+
* bot can introduce itself. A handle (`setup`) or a slug (`setup-1a2b3c4d`)
|
|
8
|
+
* is never a name, so it is never reported as one.
|
|
9
|
+
*
|
|
10
|
+
* The role marks HQ's setup assistant, so clients find it by role or agent
|
|
11
|
+
* uid and never by its name or handle.
|
|
12
|
+
*/
|
|
13
|
+
import { type BotApi, type MyLocalBot } from "./api.js";
|
|
14
|
+
import { type BotConfig } from "./config.js";
|
|
15
|
+
export declare const ASSISTANT_ROLE: "assistant";
|
|
16
|
+
export type BotRole = typeof ASSISTANT_ROLE | null;
|
|
17
|
+
/** The setup assistant's cloud slug: the handle plus the owner's suffix. */
|
|
18
|
+
export declare function setupSlugFor(ownerUid: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* A cloud name worth showing a person, or null when it is empty or is just
|
|
21
|
+
* the bot's handle or slug (bots created before names existed carry those).
|
|
22
|
+
*/
|
|
23
|
+
export declare function personFacingName(value: string | null | undefined, handles: ReadonlyArray<string | null | undefined>): string | null;
|
|
24
|
+
/** The role of a bot set up on this computer. */
|
|
25
|
+
export declare function localBotRole(config: Pick<BotConfig, "name" | "workerId">): BotRole;
|
|
26
|
+
/**
|
|
27
|
+
* The role of a bot as the cloud lists it: the server's `agentRole` marker,
|
|
28
|
+
* else (an older server) the reserved setup slug and the setup worker.
|
|
29
|
+
*/
|
|
30
|
+
export declare function remoteBotRole(bot: MyLocalBot): BotRole;
|
|
31
|
+
/** The cloud name of a listed bot, never its handle or slug. */
|
|
32
|
+
export declare function remoteBotDisplayName(bot: MyLocalBot, handle: string | null): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Where a name write ended up. "cloud": the server stored it. "local-only":
|
|
35
|
+
* the server does not have the name route yet (404/405), so only bot.json
|
|
36
|
+
* has it until the server is updated.
|
|
37
|
+
*/
|
|
38
|
+
export type NameSaved = "cloud" | "local-only";
|
|
39
|
+
export interface CloudNameWriteResult {
|
|
40
|
+
saved: NameSaved;
|
|
41
|
+
/** Why the write stayed local, for the log; never shown to a person. */
|
|
42
|
+
detail?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A 405, or a 404 from API Gateway itself, means this server has no name
|
|
46
|
+
* route yet. The route's own 404 (unknown agent) answers `{ error: "Not found" }`
|
|
47
|
+
* or carries a code, and is not a missing route: the caller has to hear that
|
|
48
|
+
* HQ does not know the bot.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isNameRouteMissing(err: unknown): boolean;
|
|
51
|
+
/** The route's own 404: HQ has no such agent (deleted, or a stale bot.json). */
|
|
52
|
+
export declare function isUnknownAgent(err: unknown): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Write a bot's name to the cloud. A server without the route is not a
|
|
55
|
+
* failure: the caller keeps the name on this computer and the result says so.
|
|
56
|
+
* Any other error is thrown for the caller to handle.
|
|
57
|
+
*/
|
|
58
|
+
export declare function writeCloudDisplayName(api: Pick<BotApi, "setDisplayName">, agentUid: string, displayName: string, opts?: {
|
|
59
|
+
attempts?: number;
|
|
60
|
+
retryDelayMs?: number;
|
|
61
|
+
}): Promise<CloudNameWriteResult>;
|
|
62
|
+
/** 409 DISPLAY_NAME_WRITE_CONFLICT: the record changed during the server's own retries. */
|
|
63
|
+
export declare function isNameWriteConflict(err: unknown): boolean;
|
|
64
|
+
//# sourceMappingURL=display-name.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bot's name and role, as the CLI reports them to the desktop app.
|
|
3
|
+
*
|
|
4
|
+
* The name people see lives in the cloud (entity name, profile display name
|
|
5
|
+
* and the saved local settings, written together by
|
|
6
|
+
* `PUT /v1/agents/{uid}/display-name`). bot.json keeps a copy so the running
|
|
7
|
+
* bot can introduce itself. A handle (`setup`) or a slug (`setup-1a2b3c4d`)
|
|
8
|
+
* is never a name, so it is never reported as one.
|
|
9
|
+
*
|
|
10
|
+
* The role marks HQ's setup assistant, so clients find it by role or agent
|
|
11
|
+
* uid and never by its name or handle.
|
|
12
|
+
*/
|
|
13
|
+
import { BotApiError } from "./api.js";
|
|
14
|
+
import { SETUP_BOT_WORKER_ID } from "./config.js";
|
|
15
|
+
import { parseBotLocalConfig } from "./local-config.js";
|
|
16
|
+
export const ASSISTANT_ROLE = "assistant";
|
|
17
|
+
/** The handle the desktop app creates HQ's setup assistant under. */
|
|
18
|
+
const SETUP_HANDLE = "setup";
|
|
19
|
+
/** The setup assistant's cloud slug: the handle plus the owner's suffix. */
|
|
20
|
+
export function setupSlugFor(ownerUid) {
|
|
21
|
+
return `${SETUP_HANDLE}-${ownerUid.trim().slice(-8).toLowerCase()}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A cloud name worth showing a person, or null when it is empty or is just
|
|
25
|
+
* the bot's handle or slug (bots created before names existed carry those).
|
|
26
|
+
*/
|
|
27
|
+
export function personFacingName(value, handles) {
|
|
28
|
+
const name = (value ?? "").trim();
|
|
29
|
+
if (!name)
|
|
30
|
+
return null;
|
|
31
|
+
const lower = name.toLowerCase();
|
|
32
|
+
for (const h of handles) {
|
|
33
|
+
if (h && h.trim().toLowerCase() === lower)
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
/** The role of a bot set up on this computer. */
|
|
39
|
+
export function localBotRole(config) {
|
|
40
|
+
return config.name === SETUP_HANDLE && config.workerId === SETUP_BOT_WORKER_ID ? ASSISTANT_ROLE : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The role of a bot as the cloud lists it: the server's `agentRole` marker,
|
|
44
|
+
* else (an older server) the reserved setup slug and the setup worker.
|
|
45
|
+
*/
|
|
46
|
+
export function remoteBotRole(bot) {
|
|
47
|
+
if (bot.agentRole === ASSISTANT_ROLE)
|
|
48
|
+
return ASSISTANT_ROLE;
|
|
49
|
+
const local = parseBotLocalConfig(bot.localConfig);
|
|
50
|
+
const owner = (bot.ownerUid ?? "").trim();
|
|
51
|
+
if (owner &&
|
|
52
|
+
(bot.slug ?? "").trim().toLowerCase() === setupSlugFor(owner) &&
|
|
53
|
+
local?.workerSource === "worker" &&
|
|
54
|
+
local.workerId === SETUP_BOT_WORKER_ID) {
|
|
55
|
+
return ASSISTANT_ROLE;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/** The cloud name of a listed bot, never its handle or slug. */
|
|
60
|
+
export function remoteBotDisplayName(bot, handle) {
|
|
61
|
+
const raw = bot;
|
|
62
|
+
const handles = [handle, bot.slug, bot.agentUid];
|
|
63
|
+
if (typeof raw.displayName === "string") {
|
|
64
|
+
const shown = personFacingName(raw.displayName, handles);
|
|
65
|
+
if (shown)
|
|
66
|
+
return shown;
|
|
67
|
+
}
|
|
68
|
+
return personFacingName(bot.name, handles) ?? personFacingName(parseBotLocalConfig(bot.localConfig)?.displayName, handles);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A 405, or a 404 from API Gateway itself, means this server has no name
|
|
72
|
+
* route yet. The route's own 404 (unknown agent) answers `{ error: "Not found" }`
|
|
73
|
+
* or carries a code, and is not a missing route: the caller has to hear that
|
|
74
|
+
* HQ does not know the bot.
|
|
75
|
+
*/
|
|
76
|
+
export function isNameRouteMissing(err) {
|
|
77
|
+
if (!(err instanceof BotApiError))
|
|
78
|
+
return false;
|
|
79
|
+
if (err.status === 405)
|
|
80
|
+
return true;
|
|
81
|
+
return err.status === 404 && !err.code && err.detail !== "Not found";
|
|
82
|
+
}
|
|
83
|
+
/** The route's own 404: HQ has no such agent (deleted, or a stale bot.json). */
|
|
84
|
+
export function isUnknownAgent(err) {
|
|
85
|
+
return err instanceof BotApiError && err.status === 404 && !isNameRouteMissing(err);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Write a bot's name to the cloud. A server without the route is not a
|
|
89
|
+
* failure: the caller keeps the name on this computer and the result says so.
|
|
90
|
+
* Any other error is thrown for the caller to handle.
|
|
91
|
+
*/
|
|
92
|
+
export async function writeCloudDisplayName(api, agentUid, displayName, opts = {}) {
|
|
93
|
+
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
94
|
+
for (let attempt = 1;; attempt++) {
|
|
95
|
+
try {
|
|
96
|
+
await api.setDisplayName(agentUid, displayName);
|
|
97
|
+
return { saved: "cloud" };
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (isNameRouteMissing(err)) {
|
|
101
|
+
return { saved: "local-only", detail: `the server has no name route yet (${err.status})` };
|
|
102
|
+
}
|
|
103
|
+
// The server gave up because the record kept changing under it; the
|
|
104
|
+
// write is safe to repeat.
|
|
105
|
+
if (isNameWriteConflict(err) && attempt < attempts) {
|
|
106
|
+
await new Promise((r) => setTimeout(r, (opts.retryDelayMs ?? 250) * attempt));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** 409 DISPLAY_NAME_WRITE_CONFLICT: the record changed during the server's own retries. */
|
|
114
|
+
export function isNameWriteConflict(err) {
|
|
115
|
+
return err instanceof BotApiError && err.status === 409 && err.code === "DISPLAY_NAME_WRITE_CONFLICT";
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=display-name.js.map
|
package/dist/lib/bot/index.d.ts
CHANGED
package/dist/lib/bot/index.js
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One writer per bot at a time.
|
|
3
|
+
*
|
|
4
|
+
* `hq bot create`, `adopt`, `restore`, `rm` and `set` all change the same
|
|
5
|
+
* bot's folder, and an adopt also rotates the bot's machine secret. Two of
|
|
6
|
+
* them running at once for the same bot (the desktop's automatic restore and
|
|
7
|
+
* its first-run create, a minute after sign-in) used to race on a
|
|
8
|
+
* check-then-write of bot.json: the loser either threw "already set up on this
|
|
9
|
+
* computer" or, worse, wrote a credential the winner had already rotated away.
|
|
10
|
+
*
|
|
11
|
+
* The lock is a file under the bots folder, `~/.hq/bots/.locks/<name>.lock`,
|
|
12
|
+
* created with O_CREAT|O_EXCL. It sits beside the bot's folder rather than in
|
|
13
|
+
* it: a folder that exists only to hold a lock would read as "a different bot
|
|
14
|
+
* already has this name" to the code that picks a restored bot's folder, and
|
|
15
|
+
* as "something is left here" to `hq bot rm`. Its body names the holder (pid,
|
|
16
|
+
* host, start time, a random token), so:
|
|
17
|
+
* - a holder whose process is gone, or that has held it for longer than
|
|
18
|
+
* {@link BOT_LOCK_STALE_MS}, is reclaimed;
|
|
19
|
+
* - release only removes the lock it took (the token must match), so a
|
|
20
|
+
* reclaim by another process never makes this process remove someone
|
|
21
|
+
* else's lock.
|
|
22
|
+
*
|
|
23
|
+
* The lock is re-entrant within one async call chain (AsyncLocalStorage):
|
|
24
|
+
* create holds it and calls the shared adopt path, which takes it again
|
|
25
|
+
* without waiting on itself. Two separate chains in one process still
|
|
26
|
+
* serialize, exactly like two processes.
|
|
27
|
+
*/
|
|
28
|
+
/** Not a valid bot name (it starts with a dot), so no bot can collide with it. */
|
|
29
|
+
export declare const BOT_LOCKS_DIRNAME = ".locks";
|
|
30
|
+
/** A holder older than this is treated as crashed, whatever its pid says. */
|
|
31
|
+
export declare const BOT_LOCK_STALE_MS: number;
|
|
32
|
+
/** How long a second writer waits for the first before giving up. */
|
|
33
|
+
export declare const BOT_LOCK_WAIT_MS: number;
|
|
34
|
+
export interface BotLockHolder {
|
|
35
|
+
pid: number;
|
|
36
|
+
host: string;
|
|
37
|
+
startedAt: string;
|
|
38
|
+
token: string;
|
|
39
|
+
command: string;
|
|
40
|
+
}
|
|
41
|
+
export interface BotLockOptions {
|
|
42
|
+
/** What is taking the lock, for the "busy" message and the lock body. */
|
|
43
|
+
command?: string;
|
|
44
|
+
/** Total wait for a live holder. Default {@link BOT_LOCK_WAIT_MS}. */
|
|
45
|
+
waitMs?: number;
|
|
46
|
+
/** Poll interval while waiting. */
|
|
47
|
+
pollMs?: number;
|
|
48
|
+
/** Stale age. Default {@link BOT_LOCK_STALE_MS}. */
|
|
49
|
+
staleMs?: number;
|
|
50
|
+
/** Bots root override (tests). */
|
|
51
|
+
root?: string;
|
|
52
|
+
now?: () => number;
|
|
53
|
+
isPidAlive?: (pid: number) => boolean;
|
|
54
|
+
/** Called once when this call has to wait for another holder. */
|
|
55
|
+
onWait?: (holder: BotLockHolder | null) => void;
|
|
56
|
+
/** Called when a stale holder is reclaimed. */
|
|
57
|
+
onReclaim?: (holder: BotLockHolder | null, why: string) => void;
|
|
58
|
+
}
|
|
59
|
+
export declare class BotLockBusyError extends Error {
|
|
60
|
+
readonly botName: string;
|
|
61
|
+
readonly holder: BotLockHolder | null;
|
|
62
|
+
readonly expected: true;
|
|
63
|
+
constructor(botName: string, holder: BotLockHolder | null);
|
|
64
|
+
}
|
|
65
|
+
export declare function botLockPath(name: string, root?: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Why an existing lock may be taken over, or null while its holder is live.
|
|
68
|
+
* Exported for tests.
|
|
69
|
+
*/
|
|
70
|
+
export declare function botLockStaleReason(raw: string, mtimeMs: number, opts: {
|
|
71
|
+
now: number;
|
|
72
|
+
staleMs: number;
|
|
73
|
+
isPidAlive: (pid: number) => boolean;
|
|
74
|
+
host: string;
|
|
75
|
+
}): string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Let go of every lock this process holds. Runs on process exit, so a command
|
|
78
|
+
* that ends through process.exit (every `hq bot` failure does) frees its bot
|
|
79
|
+
* at once instead of leaving it to the stale-holder reclaim.
|
|
80
|
+
*/
|
|
81
|
+
export declare function releaseAllHeldBotLocks(): void;
|
|
82
|
+
/** Take the bot's lock (waiting for a live holder), run `fn`, and let go. */
|
|
83
|
+
export declare function withBotLock<T>(name: string, fn: () => Promise<T>, opts?: BotLockOptions): Promise<T>;
|
|
84
|
+
//# sourceMappingURL=lock.d.ts.map
|