@indigoai-us/hq-cli 5.108.25 → 5.109.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/commands/__fixtures__/access-vault.d.ts +93 -0
  3. package/dist/commands/__fixtures__/access-vault.js +166 -0
  4. package/dist/commands/access.d.ts +158 -0
  5. package/dist/commands/access.js +783 -0
  6. package/dist/commands/cloud.js +11 -1
  7. package/dist/commands/files-browse.d.ts +25 -1
  8. package/dist/commands/files-browse.js +81 -17
  9. package/dist/commands/files.js +15 -5
  10. package/dist/commands/integrations-api.d.ts +15 -0
  11. package/dist/commands/integrations-connect.js +84 -3
  12. package/dist/commands/integrations-oauth.js +62 -3
  13. package/dist/commands/mcp-registration.d.ts +17 -7
  14. package/dist/commands/mcp-registration.js +16 -27
  15. package/dist/commands/mesh.js +174 -50
  16. package/dist/commands/pack-install.js +5 -5
  17. package/dist/commands/secrets.d.ts +7 -0
  18. package/dist/commands/secrets.js +26 -2
  19. package/dist/commands/sync-mode.js +12 -1
  20. package/dist/commands/sync-narrow.js +12 -1
  21. package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
  22. package/dist/lib/mesh/live/backfill-held.js +95 -13
  23. package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
  24. package/dist/lib/mesh/live/daemon/doctor.js +41 -10
  25. package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
  26. package/dist/lib/mesh/live/daemon/mode.js +88 -0
  27. package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
  28. package/dist/lib/mesh/live/daemon/run.js +39 -28
  29. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  30. package/dist/lib/mesh/live/emit-client.d.ts +99 -0
  31. package/dist/lib/mesh/live/emit-client.js +193 -0
  32. package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
  33. package/dist/lib/mesh/live/emit-evidence.js +77 -0
  34. package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
  35. package/dist/lib/mesh/live/emit-replay.js +157 -0
  36. package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
  37. package/dist/lib/mesh/live/emit-retry.js +79 -0
  38. package/dist/lib/mesh/live/emit.d.ts +54 -0
  39. package/dist/lib/mesh/live/emit.js +153 -0
  40. package/dist/lib/narrow-hint-banner.d.ts +3 -7
  41. package/dist/lib/narrow-hint-banner.js +13 -34
  42. package/dist/lib/plan-limit-nag.d.ts +0 -3
  43. package/dist/lib/plan-limit-nag.js +10 -20
  44. package/dist/register-all.js +3 -0
  45. package/dist/utils/access-denied-hint.d.ts +32 -0
  46. package/dist/utils/access-denied-hint.js +139 -0
  47. package/dist/utils/access-requests.d.ts +28 -0
  48. package/dist/utils/access-requests.js +98 -0
  49. package/package.json +1 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Direct-emit orchestration (owner decision 2026-09-08). One invocation:
3
+ * 1. drains the local retry file (prior failures),
4
+ * 2. appends the new event(s),
5
+ * 3. POSTs /v1/mesh/events in batches with a short bounded retry,
6
+ * 4. keeps only network/5xx/429-failed or unaccounted events for next time,
7
+ * 5. records lastPostAt + per-event status counts for `hq mesh doctor`.
8
+ *
9
+ * accepted / unassigned / rejected are all terminal (rejected is dropped with a
10
+ * count — never re-posted). No long-lived process, spool, or held queue.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { MESH_EVENTS_BATCH_MAX, parseEmitResults, } from "./emit-client.js";
15
+ import { EMIT_RETRY_MAX, readEmitRetry, writeEmitRetry, } from "./emit-retry.js";
16
+ import { defaultSleep, fullJitterDelayMs, } from "./backoff.js";
17
+ export function emitStatePath(workMeshRoot) {
18
+ return path.join(workMeshRoot, "emit-state.json");
19
+ }
20
+ export function readEmitState(workMeshRoot) {
21
+ try {
22
+ const raw = fs.readFileSync(emitStatePath(workMeshRoot), "utf8");
23
+ const v = JSON.parse(raw);
24
+ if (v && typeof v === "object" && !Array.isArray(v))
25
+ return v;
26
+ }
27
+ catch {
28
+ /* absent / malformed */
29
+ }
30
+ return null;
31
+ }
32
+ function writeEmitState(workMeshRoot, state) {
33
+ const p = emitStatePath(workMeshRoot);
34
+ try {
35
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
36
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
37
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
38
+ fs.renameSync(tmp, p);
39
+ }
40
+ catch {
41
+ /* best-effort */
42
+ }
43
+ }
44
+ function eventIdOf(e) {
45
+ return typeof e.eventId === "string" && e.eventId.trim() ? e.eventId.trim() : null;
46
+ }
47
+ /** Drain the retry file, post pending + new events, persist failures. */
48
+ export async function emitEvents(deps) {
49
+ const now = deps.now ?? (() => new Date());
50
+ const sleep = deps.sleep ?? defaultSleep;
51
+ const random = deps.random ?? Math.random;
52
+ const maxAttempts = deps.maxAttempts ?? 3;
53
+ const retryMax = deps.retryMax ?? EMIT_RETRY_MAX;
54
+ // Combine pending retry + new events, deduped by eventId (first wins).
55
+ const pending = readEmitRetry(deps.workMeshRoot);
56
+ const combined = [];
57
+ const seen = new Set();
58
+ for (const e of [...pending, ...(deps.newEvents ?? [])]) {
59
+ const id = eventIdOf(e);
60
+ if (id) {
61
+ if (seen.has(id))
62
+ continue;
63
+ seen.add(id);
64
+ }
65
+ combined.push(e);
66
+ }
67
+ const summary = {
68
+ attempted: combined.length,
69
+ accepted: 0,
70
+ unassigned: 0,
71
+ rejected: 0,
72
+ retained: 0,
73
+ droppedOverflow: 0,
74
+ retryDepth: 0,
75
+ batches: 0,
76
+ statuses: { accepted: 0, unassigned: 0, rejected: 0 },
77
+ };
78
+ const keep = [];
79
+ let anyPosted = false;
80
+ for (let i = 0; i < combined.length; i += MESH_EVENTS_BATCH_MAX) {
81
+ const chunk = combined.slice(i, i + MESH_EVENTS_BATCH_MAX);
82
+ summary.batches += 1;
83
+ let result = await deps.poster(chunk);
84
+ for (let attempt = 1; attempt < maxAttempts && !result.ok && result.retryable; attempt += 1) {
85
+ await sleep(fullJitterDelayMs(attempt - 1, { random }));
86
+ result = await deps.poster(chunk);
87
+ }
88
+ if (!result.ok) {
89
+ if (result.retryable) {
90
+ // Network/5xx/429 after retries → keep the whole chunk for next time.
91
+ keep.push(...chunk);
92
+ deps.log?.(`emit batch retained (${chunk.length}) status=${result.status}`);
93
+ }
94
+ else {
95
+ // Non-retryable 4xx (e.g. 401/400) → drop with a log (never user data).
96
+ summary.rejected += chunk.length;
97
+ summary.statuses.rejected += chunk.length;
98
+ deps.log?.(`emit batch dropped (${chunk.length}) non-retryable status=${result.status}`);
99
+ }
100
+ continue;
101
+ }
102
+ anyPosted = true;
103
+ const results = parseEmitResults(result.body);
104
+ if (!results) {
105
+ // Unparseable 2xx → retain rather than pretend posted.
106
+ keep.push(...chunk);
107
+ deps.log?.(`emit batch retained (${chunk.length}) unparseable 2xx`);
108
+ continue;
109
+ }
110
+ const byId = new Map(results.map((r) => [r.eventId, r]));
111
+ for (const e of chunk) {
112
+ const id = eventIdOf(e);
113
+ const r = id ? byId.get(id) : undefined;
114
+ if (!r) {
115
+ // Unaccounted event in a 2xx → retain (don't lose it).
116
+ keep.push(e);
117
+ continue;
118
+ }
119
+ summary.statuses[r.status] += 1;
120
+ if (r.status === "accepted")
121
+ summary.accepted += 1;
122
+ else if (r.status === "unassigned")
123
+ summary.unassigned += 1;
124
+ else
125
+ summary.rejected += 1;
126
+ }
127
+ }
128
+ const written = writeEmitRetry(deps.workMeshRoot, keep, retryMax);
129
+ summary.retained = written.written;
130
+ summary.droppedOverflow = written.dropped;
131
+ summary.retryDepth = written.written;
132
+ const nowIso = now().toISOString();
133
+ const prior = readEmitState(deps.workMeshRoot) ?? {};
134
+ const nextState = {
135
+ ...prior,
136
+ lastAttemptAt: nowIso,
137
+ lastAccepted: summary.accepted,
138
+ lastUnassigned: summary.unassigned,
139
+ lastRejected: summary.rejected,
140
+ lastRetryDepth: summary.retryDepth,
141
+ };
142
+ if (anyPosted) {
143
+ nextState.lastPostAt = nowIso;
144
+ summary.lastPostAt = nowIso;
145
+ delete nextState.lastError;
146
+ }
147
+ else if (combined.length > 0) {
148
+ nextState.lastError = "emit failed (network/server); retained for retry";
149
+ }
150
+ writeEmitState(deps.workMeshRoot, nextState);
151
+ return summary;
152
+ }
153
+ //# sourceMappingURL=emit.js.map
@@ -22,7 +22,6 @@
22
22
  * - the env var `HQ_SYNC_NARROW_HINT=off` is set,
23
23
  * - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
24
24
  * `syncNarrowHint: 'off'`,
25
- * - the synced flag registry disables `sync.narrow-hint`,
26
25
  * - or the same `{companyUid, level}` pair has already been shown this
27
26
  * process (module-singleton dedupe; the runner imports the same module
28
27
  * once per `hq` invocation so a single invocation prints at most one
@@ -47,7 +46,6 @@
47
46
  * `syncNarrowHintMinBytes` — see `resolveNarrowHintMinBytes`.
48
47
  */
49
48
  import * as fs from "node:fs";
50
- import { type FlagReader } from "./flag-registry.js";
51
49
  export type BannerLevel = "hint" | "warning" | "strict";
52
50
  /**
53
51
  * Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
@@ -87,8 +85,6 @@ export interface ShouldShowBannerOpts {
87
85
  readFile?: (p: string) => string;
88
86
  /** Test seam: override `fs.existsSync`. */
89
87
  existsFile?: (p: string) => boolean;
90
- /** Test seam: held registry snapshot reader. */
91
- flagReader?: FlagReader;
92
88
  }
93
89
  /**
94
90
  * Decides whether a banner should be printed AT ALL — independent of
@@ -154,12 +150,12 @@ export declare function companyFolderExceedsThreshold(companyDir: string, thresh
154
150
  * `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
155
151
  * folder is under the threshold is never refused.
156
152
  */
157
- export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel, flagReader?: FlagReader): boolean;
153
+ export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel): boolean;
158
154
  /**
159
155
  * Keep the rendered banner honest about the decision that this invocation
160
156
  * actually made. Hint and warning remain local presentation choices. `strict`
161
- * is reserved for a real refusal, so a registry opt-out of an old strict level
162
- * degrades to the local warning presentation instead of claiming a block.
157
+ * is reserved for a real refusal, so a strict level that did NOT produce a
158
+ * refusal degrades to the local warning presentation instead of claiming a block.
163
159
  */
164
160
  export declare function resolveNarrowHintPresentationLevel(level: BannerLevel, strictRefusal: boolean): BannerLevel;
165
161
  /**
@@ -22,7 +22,6 @@
22
22
  * - the env var `HQ_SYNC_NARROW_HINT=off` is set,
23
23
  * - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
24
24
  * `syncNarrowHint: 'off'`,
25
- * - the synced flag registry disables `sync.narrow-hint`,
26
25
  * - or the same `{companyUid, level}` pair has already been shown this
27
26
  * process (module-singleton dedupe; the runner imports the same module
28
27
  * once per `hq` invocation so a single invocation prints at most one
@@ -49,7 +48,6 @@
49
48
  import chalk from "chalk";
50
49
  import * as fs from "node:fs";
51
50
  import * as path from "node:path";
52
- import { resolveFlagGate, resolveProcessFlagGate, } from "./flag-registry.js";
53
51
  /**
54
52
  * Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
55
53
  * smaller than this is cheap to keep in full, so all-mode is left alone and no
@@ -101,21 +99,11 @@ export function shouldShowBanner(opts = {}) {
101
99
  }
102
100
  }
103
101
  }
104
- const lookup = {
105
- globalEnvVar: "HQ_SYNC_NARROW_HINT",
106
- globalValueSemantics: {
107
- onValues: [],
108
- offValues: ["off"],
109
- unrecognizedValue: true,
110
- unsetValue: true,
111
- // Deliberately no `trim`: the legacy parser only case-folds.
112
- caseInsensitive: true,
113
- },
114
- fallback: true,
115
- };
116
- return opts.flagReader
117
- ? resolveFlagGate(opts.flagReader, "sync.narrow-hint", lookup, () => true)
118
- : resolveProcessFlagGate("sync.narrow-hint", lookup, () => true);
102
+ // The env var (`HQ_SYNC_NARROW_HINT=off`, evaluated above) and the
103
+ // `.hq/config.json` off-switch are the ONLY ways to suppress the banner. This
104
+ // is a personal preference, not a rollout flag, so nothing else gates it:
105
+ // reaching here means neither off-switch fired, so the banner shows.
106
+ return true;
119
107
  }
120
108
  /**
121
109
  * Resolve the banner level from environment overrides. Defaults to
@@ -246,29 +234,20 @@ export function companyFolderExceedsThreshold(companyDir, thresholdBytes, deps =
246
234
  * `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
247
235
  * folder is under the threshold is never refused.
248
236
  */
249
- export function isStrictRefusal(syncMode, level, flagReader) {
237
+ export function isStrictRefusal(syncMode, level) {
250
238
  if (syncMode !== "all")
251
239
  return false;
252
- const lookup = {
253
- globalEnvVar: "HQ_SYNC_NARROW_HINT_LEVEL",
254
- globalValueSemantics: {
255
- onValues: ["strict"],
256
- offValues: ["hint", "warning"],
257
- unrecognizedValue: false,
258
- unsetValue: false,
259
- caseInsensitive: true,
260
- },
261
- fallback: level === "strict",
262
- };
263
- return flagReader
264
- ? resolveFlagGate(flagReader, "sync.narrow-hint-strict", lookup, () => level === "strict")
265
- : resolveProcessFlagGate("sync.narrow-hint-strict", lookup, () => level === "strict");
240
+ // The refusal is driven purely by the caller's `level`, which is itself
241
+ // resolved from the operator's own `HQ_SYNC_NARROW_HINT_LEVEL` (see
242
+ // `resolveBannerLevel`). This is a personal escalation choice, not a rollout
243
+ // flag, so no registry can independently force or clear the strict refusal.
244
+ return level === "strict";
266
245
  }
267
246
  /**
268
247
  * Keep the rendered banner honest about the decision that this invocation
269
248
  * actually made. Hint and warning remain local presentation choices. `strict`
270
- * is reserved for a real refusal, so a registry opt-out of an old strict level
271
- * degrades to the local warning presentation instead of claiming a block.
249
+ * is reserved for a real refusal, so a strict level that did NOT produce a
250
+ * refusal degrades to the local warning presentation instead of claiming a block.
272
251
  */
273
252
  export function resolveNarrowHintPresentationLevel(level, strictRefusal) {
274
253
  if (strictRefusal)
@@ -15,7 +15,6 @@
15
15
  * Additive only — never throws, never touches `process.exitCode`, never
16
16
  * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1`.
17
17
  */
18
- import { type FlagReader } from "./flag-registry.js";
19
18
  export declare const PLAN_LIMIT_UPGRADE_URL = "https://app.indigo-hq.com/billing/upgrade";
20
19
  export interface PlanLimitEntry {
21
20
  used: number;
@@ -40,8 +39,6 @@ export declare function emitPlanLimitNag(opts?: {
40
39
  write?: (s: string) => void;
41
40
  now?: () => Date;
42
41
  statePath?: string;
43
- /** Test seam: held registry snapshot reader. */
44
- flagReader?: FlagReader;
45
42
  }): void;
46
43
  /** Test-only helper — clears last-seen status and session dedupe flags. */
47
44
  export declare function _resetForTests(): void;
@@ -19,7 +19,6 @@ import chalk from "chalk";
19
19
  import * as fs from "node:fs";
20
20
  import * as os from "node:os";
21
21
  import * as path from "node:path";
22
- import { resolveFlagGate, resolveProcessFlagGate, } from "./flag-registry.js";
23
22
  export const PLAN_LIMIT_UPGRADE_URL = "https://app.indigo-hq.com/billing/upgrade";
24
23
  const DAY_MS = 24 * 60 * 60 * 1000;
25
24
  /** Module-level last-seen cell — overwritten by each successful parse. */
@@ -31,24 +30,15 @@ let overShownThisSession = false;
31
30
  function defaultStatePath() {
32
31
  return path.join(os.homedir(), ".hq", "plan-limit-nag.json");
33
32
  }
34
- function isOptedOut() {
35
- return process.env.HQ_NO_PLAN_LIMIT_NAG === "1";
36
- }
37
- function isPlanLimitNagEnabled(flagReader) {
38
- const lookup = {
39
- globalEnvVar: "HQ_NO_PLAN_LIMIT_NAG",
40
- globalValueSemantics: {
41
- onValues: [],
42
- offValues: ["1"],
43
- unrecognizedValue: true,
44
- unsetValue: true,
45
- },
46
- fallback: true,
47
- };
48
- const legacyFallback = () => !isOptedOut();
49
- return flagReader
50
- ? resolveFlagGate(flagReader, "cli.plan-limit-nag", lookup, legacyFallback)
51
- : resolveProcessFlagGate("cli.plan-limit-nag", lookup, legacyFallback);
33
+ function isPlanLimitNagEnabled() {
34
+ // Personal opt-out only: `HQ_NO_PLAN_LIMIT_NAG=1` silences the nag on THIS
35
+ // machine. This is a user preference, not a rollout flag, so it is read
36
+ // straight from the environment — no registry lookup, so no future
37
+ // registration can revoke the opt-out. The value match is asymmetric on
38
+ // purpose: ONLY the exact value "1" turns the nag off; unset and every other
39
+ // value (including "0" and "false") keep it on. Do not "tidy" this into a
40
+ // boolean parse — that would silently silence anyone who wrote "false".
41
+ return process.env.HQ_NO_PLAN_LIMIT_NAG !== "1";
52
42
  }
53
43
  /**
54
44
  * Defensively parse a single planLimits entry. Returns null if the shape is
@@ -181,7 +171,7 @@ function buildOverBox(overEntries) {
181
171
  */
182
172
  export function emitPlanLimitNag(opts = {}) {
183
173
  try {
184
- if (!isPlanLimitNagEnabled(opts.flagReader))
174
+ if (!isPlanLimitNagEnabled())
185
175
  return;
186
176
  if (lastSeen === null)
187
177
  return;
@@ -57,6 +57,7 @@ import { registerWorkersCommand } from "./commands/workers.js";
57
57
  import { registerGroupGrantsCommand } from "./commands/group-grants.js";
58
58
  import { registerFilesCommand } from "./commands/files.js";
59
59
  import { registerFilesBrowseCommands } from "./commands/files-browse.js";
60
+ import { registerAccessCommand } from "./commands/access.js";
60
61
  import { registerSkillCommand } from "./commands/skill.js";
61
62
  import { registerMembersCommand } from "./commands/members.js";
62
63
  import { registerPeopleCommand } from "./commands/people.js";
@@ -157,6 +158,8 @@ export function registerAllCommands(program) {
157
158
  // browse-vs-sync subcommands (`hq files browse`/`cat`) onto the same group.
158
159
  const filesCmd = registerFilesCommand(program);
159
160
  registerFilesBrowseCommands(filesCmd);
161
+ // Top-level `hq access` — vault existence + ACL probe (self-healing ladder).
162
+ registerAccessCommand(program);
160
163
  // Comment-only skill improvement loop. Structured suggestion/review commands are
161
164
  // intentionally absent; live content changes remain governed by FILE_ACL sync.
162
165
  registerSkillCommand(program);
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared 403 / access-denied formatter for `hq files` and `hq sync`.
3
+ *
4
+ * `src/commands/sync.ts` is the legacy modules-sync command and has no 403
5
+ * path — leave it untouched; this util is the only 403 hint surface.
6
+ */
7
+ export declare const ACCESS_HINT_PREFIX = "Run: hq access";
8
+ export declare function accessDeniedHint(path?: string): string;
9
+ export declare function formatAccessDenied(message: string, path?: string): string;
10
+ export declare function isAccessDeniedError(err: unknown): boolean;
11
+ export declare function accessDeniedKeyOf(err: unknown): string | undefined;
12
+ /**
13
+ * Company slug the denied operation was running against, when the thrower
14
+ * attached one (`createCompanyPresignClient` does). Keys on 403 errors are
15
+ * bucket-relative, so without this the ladder would fall back to the ACTIVE
16
+ * company and could probe/DM the wrong tenant.
17
+ */
18
+ export declare function accessDeniedCompanyOf(err: unknown): string | undefined;
19
+ /** Tenant anchor forwarded to `hq access` so it never guesses the company. */
20
+ export interface AccessLadderContext {
21
+ company?: string;
22
+ hqRoot?: string;
23
+ }
24
+ export interface OfferAccessLadderOptions extends AccessLadderContext {
25
+ isTTY?: boolean;
26
+ ask?: (question: string) => Promise<boolean>;
27
+ run?: (path: string, ctx?: AccessLadderContext) => Promise<unknown>;
28
+ stderr?: (line: string) => void;
29
+ }
30
+ export declare function offerAccessLadder(path: string | undefined, opts?: OfferAccessLadderOptions): Promise<boolean>;
31
+ export declare function reportAccessDenied(message: string, path: string | undefined, opts?: OfferAccessLadderOptions): Promise<void>;
32
+ //# sourceMappingURL=access-denied-hint.d.ts.map
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Shared 403 / access-denied formatter for `hq files` and `hq sync`.
3
+ *
4
+ * `src/commands/sync.ts` is the legacy modules-sync command and has no 403
5
+ * path — leave it untouched; this util is the only 403 hint surface.
6
+ */
7
+ import * as readline from "node:readline";
8
+ export const ACCESS_HINT_PREFIX = "Run: hq access";
9
+ export function accessDeniedHint(path) {
10
+ const target = path && path.length > 0 ? path : "<path>";
11
+ return `${ACCESS_HINT_PREFIX} ${target}`;
12
+ }
13
+ export function formatAccessDenied(message, path) {
14
+ return `${message}\n${accessDeniedHint(path)}`;
15
+ }
16
+ function errRecord(err) {
17
+ if (typeof err === "object" && err !== null)
18
+ return err;
19
+ return undefined;
20
+ }
21
+ function errMessage(err) {
22
+ if (err instanceof Error)
23
+ return err.message;
24
+ if (typeof err === "string")
25
+ return err;
26
+ const rec = errRecord(err);
27
+ if (rec && typeof rec.message === "string")
28
+ return rec.message;
29
+ return "";
30
+ }
31
+ export function isAccessDeniedError(err) {
32
+ const rec = errRecord(err);
33
+ if (rec) {
34
+ if (rec.status === 403)
35
+ return true;
36
+ const meta = rec.$metadata;
37
+ if (typeof meta === "object" &&
38
+ meta !== null &&
39
+ meta.httpStatusCode === 403) {
40
+ return true;
41
+ }
42
+ if (rec.code === "FILES_PRESIGN_FORBIDDEN")
43
+ return true;
44
+ }
45
+ return /\b403\b|forbidden|not authorized|access denied/i.test(errMessage(err));
46
+ }
47
+ export function accessDeniedKeyOf(err) {
48
+ const rec = errRecord(err);
49
+ if (!rec)
50
+ return undefined;
51
+ if (typeof rec.key === "string" && rec.key.length > 0)
52
+ return rec.key;
53
+ if (typeof rec.path === "string" && rec.path.length > 0)
54
+ return rec.path;
55
+ return undefined;
56
+ }
57
+ /**
58
+ * Company slug the denied operation was running against, when the thrower
59
+ * attached one (`createCompanyPresignClient` does). Keys on 403 errors are
60
+ * bucket-relative, so without this the ladder would fall back to the ACTIVE
61
+ * company and could probe/DM the wrong tenant.
62
+ */
63
+ export function accessDeniedCompanyOf(err) {
64
+ const rec = errRecord(err);
65
+ if (!rec)
66
+ return undefined;
67
+ if (typeof rec.company === "string" && rec.company.length > 0)
68
+ return rec.company;
69
+ return undefined;
70
+ }
71
+ function defaultIsTTY() {
72
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
73
+ }
74
+ async function defaultAsk(question) {
75
+ const rl = readline.createInterface({
76
+ input: process.stdin,
77
+ output: process.stderr,
78
+ });
79
+ const answer = await new Promise((resolve) => {
80
+ rl.question(`${question} `, (line) => {
81
+ rl.close();
82
+ resolve(line);
83
+ });
84
+ });
85
+ const t = answer.trim().toLowerCase();
86
+ return t === "y" || t === "yes";
87
+ }
88
+ async function defaultRun(path, ctx) {
89
+ const m = await import("../commands/access.js");
90
+ return m.runAccessForPath(path, ctx);
91
+ }
92
+ function ladderContext(opts) {
93
+ if (!opts)
94
+ return undefined;
95
+ const ctx = {};
96
+ if (opts.company)
97
+ ctx.company = opts.company;
98
+ if (opts.hqRoot)
99
+ ctx.hqRoot = opts.hqRoot;
100
+ return Object.keys(ctx).length > 0 ? ctx : undefined;
101
+ }
102
+ export async function offerAccessLadder(path, opts) {
103
+ const stderr = opts?.stderr ?? ((line) => console.error(line));
104
+ stderr(accessDeniedHint(path));
105
+ const isTTY = opts?.isTTY ?? defaultIsTTY();
106
+ if (!isTTY || !path)
107
+ return false;
108
+ const ask = opts?.ask ?? defaultAsk;
109
+ const question = "Run it now? [y/N]";
110
+ let yes;
111
+ try {
112
+ yes = await ask(question);
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ if (!yes)
118
+ return false;
119
+ const run = opts?.run ?? defaultRun;
120
+ const ctx = ladderContext(opts);
121
+ try {
122
+ if (ctx)
123
+ await run(path, ctx);
124
+ else
125
+ await run(path);
126
+ return true;
127
+ }
128
+ catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ stderr(`Error: ${message}`);
131
+ return false;
132
+ }
133
+ }
134
+ export async function reportAccessDenied(message, path, opts) {
135
+ const stderr = opts?.stderr ?? ((line) => console.error(line));
136
+ stderr(message);
137
+ await offerAccessLadder(path, opts);
138
+ }
139
+ //# sourceMappingURL=access-denied-hint.js.map
@@ -0,0 +1,28 @@
1
+ export declare const ACCESS_REQUEST_DEDUPE_MS: number;
2
+ export interface AccessRequestRecord {
3
+ requester: string;
4
+ prefix: string;
5
+ company: string;
6
+ grantor: string;
7
+ sentAt: string;
8
+ eventId?: string;
9
+ }
10
+ export declare function accessRequestsPath(hqRoot: string): string;
11
+ /**
12
+ * Read the ledger. A missing file is the empty ledger; any other failure
13
+ * (unreadable, corrupt JSON, non-array shape) is rethrown with the ledger path
14
+ * so a corrupt file is never silently replaced by the next write.
15
+ */
16
+ export declare function readAccessRequests(hqRoot: string): AccessRequestRecord[];
17
+ export declare function recordAccessRequest(hqRoot: string, rec: AccessRequestRecord): void;
18
+ export declare function findRecentAccessRequest(hqRoot: string, args: {
19
+ requester: string;
20
+ prefix: string;
21
+ /** Company slug — `prefix` is company-relative, so it is only unique per company. */
22
+ company: string;
23
+ grantor: string;
24
+ now: number;
25
+ windowMs?: number;
26
+ }): AccessRequestRecord | null;
27
+ export declare function formatTimeAgo(iso: string, nowMs: number): string;
28
+ //# sourceMappingURL=access-requests.d.ts.map
@@ -0,0 +1,98 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ export const ACCESS_REQUEST_DEDUPE_MS = 24 * 60 * 60 * 1000;
4
+ export function accessRequestsPath(hqRoot) {
5
+ return path.join(hqRoot, ".hq", "access-requests.json");
6
+ }
7
+ /**
8
+ * Read the ledger. A missing file is the empty ledger; any other failure
9
+ * (unreadable, corrupt JSON, non-array shape) is rethrown with the ledger path
10
+ * so a corrupt file is never silently replaced by the next write.
11
+ */
12
+ export function readAccessRequests(hqRoot) {
13
+ const file = accessRequestsPath(hqRoot);
14
+ let raw;
15
+ try {
16
+ raw = fs.readFileSync(file, "utf-8");
17
+ }
18
+ catch (err) {
19
+ if (err.code === "ENOENT")
20
+ return [];
21
+ const message = err instanceof Error ? err.message : String(err);
22
+ throw new Error(`Cannot read access-request ledger at ${file}: ${message}`);
23
+ }
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(raw);
27
+ }
28
+ catch (err) {
29
+ const message = err instanceof Error ? err.message : String(err);
30
+ throw new Error(`Corrupt access-request ledger at ${file}: ${message}. Repair or remove the file; it was not overwritten.`);
31
+ }
32
+ if (!Array.isArray(parsed)) {
33
+ throw new Error(`Corrupt access-request ledger at ${file}: expected a JSON array. Repair or remove the file; it was not overwritten.`);
34
+ }
35
+ return parsed;
36
+ }
37
+ export function recordAccessRequest(hqRoot, rec) {
38
+ // Read first: a corrupt ledger throws here, before anything is written.
39
+ const existing = readAccessRequests(hqRoot);
40
+ existing.push(rec);
41
+ const file = accessRequestsPath(hqRoot);
42
+ fs.mkdirSync(path.dirname(file), { recursive: true });
43
+ // Atomic replace: write a sibling tmp file, then rename over the ledger so a
44
+ // crash mid-write can never leave a truncated file behind.
45
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
46
+ try {
47
+ fs.writeFileSync(tmp, JSON.stringify(existing, null, 2), "utf-8");
48
+ fs.renameSync(tmp, file);
49
+ }
50
+ finally {
51
+ // A failed rename must not strand the tmp file next to the ledger.
52
+ if (fs.existsSync(tmp))
53
+ fs.unlinkSync(tmp);
54
+ }
55
+ }
56
+ export function findRecentAccessRequest(hqRoot, args) {
57
+ const windowMs = args.windowMs ?? ACCESS_REQUEST_DEDUPE_MS;
58
+ const rows = readAccessRequests(hqRoot);
59
+ let latest = null;
60
+ for (const row of rows) {
61
+ if (row.requester !== args.requester ||
62
+ row.prefix !== args.prefix ||
63
+ row.company !== args.company ||
64
+ row.grantor !== args.grantor) {
65
+ continue;
66
+ }
67
+ const t = Date.parse(row.sentAt);
68
+ if (!Number.isFinite(t))
69
+ continue;
70
+ if (args.now - t > windowMs)
71
+ continue;
72
+ if (!latest || Date.parse(latest.sentAt) < t)
73
+ latest = row;
74
+ }
75
+ return latest;
76
+ }
77
+ export function formatTimeAgo(iso, nowMs) {
78
+ const t = Date.parse(iso);
79
+ if (!Number.isFinite(t))
80
+ return "just now";
81
+ const delta = Math.max(0, nowMs - t);
82
+ const minute = 60 * 1000;
83
+ const hour = 60 * minute;
84
+ const day = 24 * hour;
85
+ if (delta < minute)
86
+ return "just now";
87
+ if (delta < hour) {
88
+ const n = Math.floor(delta / minute);
89
+ return `${n} minute${n === 1 ? "" : "s"} ago`;
90
+ }
91
+ if (delta < day) {
92
+ const n = Math.floor(delta / hour);
93
+ return `${n} hour${n === 1 ? "" : "s"} ago`;
94
+ }
95
+ const n = Math.floor(delta / day);
96
+ return `${n} day${n === 1 ? "" : "s"} ago`;
97
+ }
98
+ //# sourceMappingURL=access-requests.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.25",
3
+ "version": "5.109.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {