@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.
- package/CHANGELOG.md +88 -0
- package/dist/commands/__fixtures__/access-vault.d.ts +93 -0
- package/dist/commands/__fixtures__/access-vault.js +166 -0
- package/dist/commands/access.d.ts +158 -0
- package/dist/commands/access.js +783 -0
- package/dist/commands/cloud.js +11 -1
- package/dist/commands/files-browse.d.ts +25 -1
- package/dist/commands/files-browse.js +81 -17
- package/dist/commands/files.js +15 -5
- package/dist/commands/integrations-api.d.ts +15 -0
- package/dist/commands/integrations-connect.js +84 -3
- package/dist/commands/integrations-oauth.js +62 -3
- package/dist/commands/mcp-registration.d.ts +17 -7
- package/dist/commands/mcp-registration.js +16 -27
- package/dist/commands/mesh.js +174 -50
- package/dist/commands/pack-install.js +5 -5
- package/dist/commands/secrets.d.ts +7 -0
- package/dist/commands/secrets.js +26 -2
- package/dist/commands/sync-mode.js +12 -1
- package/dist/commands/sync-narrow.js +12 -1
- package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
- package/dist/lib/mesh/live/backfill-held.js +95 -13
- package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
- package/dist/lib/mesh/live/daemon/doctor.js +41 -10
- package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/mode.js +88 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
- package/dist/lib/mesh/live/daemon/run.js +39 -28
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/emit-client.d.ts +99 -0
- package/dist/lib/mesh/live/emit-client.js +193 -0
- package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
- package/dist/lib/mesh/live/emit-evidence.js +77 -0
- package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
- package/dist/lib/mesh/live/emit-replay.js +157 -0
- package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
- package/dist/lib/mesh/live/emit-retry.js +79 -0
- package/dist/lib/mesh/live/emit.d.ts +54 -0
- package/dist/lib/mesh/live/emit.js +153 -0
- package/dist/lib/narrow-hint-banner.d.ts +3 -7
- package/dist/lib/narrow-hint-banner.js +13 -34
- package/dist/lib/plan-limit-nag.d.ts +0 -3
- package/dist/lib/plan-limit-nag.js +10 -20
- package/dist/register-all.js +3 -0
- package/dist/utils/access-denied-hint.d.ts +32 -0
- package/dist/utils/access-denied-hint.js +139 -0
- package/dist/utils/access-requests.d.ts +28 -0
- package/dist/utils/access-requests.js +98 -0
- package/package.json +1 -1
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq access <path-or-query>` — existence + ACL probe with a self-healing
|
|
3
|
+
* ladder: resolve, list, probe ACL, then (with --fix) download / pin / repair
|
|
4
|
+
* sync or request access from the grantor.
|
|
5
|
+
*
|
|
6
|
+
* Tells a teammate whether a vault file exists and whether they can read it,
|
|
7
|
+
* so they never see a bare not-found.
|
|
8
|
+
*
|
|
9
|
+
* Vending uses the multi-tenant STS route `vaultClient.sts.vend({ companyUid })`
|
|
10
|
+
* (`/sts/vend`). The legacy `POST /vend` is never used — it assumes a single
|
|
11
|
+
* static BUCKET_ARN that is unset in multi-tenant prod.
|
|
12
|
+
*
|
|
13
|
+
* Company vault keys are company-relative; the CLI speaks the anchored form
|
|
14
|
+
* `companies/<slug>/...` for display.
|
|
15
|
+
*/
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as readline from "node:readline";
|
|
20
|
+
import { ListObjectsV2Command, } from "@aws-sdk/client-s3";
|
|
21
|
+
import { DEFAULT_HQ_ROOT } from "../utils/cognito-session.js";
|
|
22
|
+
import { getCompanyUid, vaultApiFetch, resolveCallerPersonUid } from "../utils/vault-api.js";
|
|
23
|
+
import { parseCompanySlugFromPath, resolveBrowseSession, makeCompanyPresignFactory, runGet, } from "./files-browse.js";
|
|
24
|
+
import { listJournals, syncDoctor, } from "@indigoai-us/hq-cloud";
|
|
25
|
+
import { listActiveMembers } from "./members.js";
|
|
26
|
+
import { buildDmBody } from "./dm.js";
|
|
27
|
+
import { peekIdToken } from "../utils/id-token.js";
|
|
28
|
+
import { findRecentAccessRequest, formatTimeAgo, recordAccessRequest, } from "../utils/access-requests.js";
|
|
29
|
+
export function outcomeExitCode(outcome) {
|
|
30
|
+
if (outcome === "local" || outcome === "not-synced")
|
|
31
|
+
return 0;
|
|
32
|
+
if (outcome === "never-existed")
|
|
33
|
+
return 2;
|
|
34
|
+
if (outcome === "no-access" || outcome === "pending-confirmation")
|
|
35
|
+
return 3;
|
|
36
|
+
return 4;
|
|
37
|
+
}
|
|
38
|
+
export function formatAccessOutcome(result) {
|
|
39
|
+
const lines = [];
|
|
40
|
+
switch (result.outcome) {
|
|
41
|
+
case "never-existed":
|
|
42
|
+
lines.push(`This path was never created in the ${result.company} vault.`);
|
|
43
|
+
lines.push(`Looked up: ${result.path}`);
|
|
44
|
+
break;
|
|
45
|
+
case "local":
|
|
46
|
+
lines.push(`The file is local under your HQ tree.`);
|
|
47
|
+
lines.push(result.path);
|
|
48
|
+
break;
|
|
49
|
+
case "not-synced":
|
|
50
|
+
lines.push(`The file exists in the vault but is not synced locally.`);
|
|
51
|
+
lines.push(result.path);
|
|
52
|
+
break;
|
|
53
|
+
case "no-access":
|
|
54
|
+
lines.push(`The file exists in the vault, but you do not have read access.`);
|
|
55
|
+
lines.push(result.path);
|
|
56
|
+
break;
|
|
57
|
+
case "pending-confirmation":
|
|
58
|
+
lines.push(`Ask the grantor for read access to this path.`);
|
|
59
|
+
lines.push(result.path);
|
|
60
|
+
break;
|
|
61
|
+
case "ambiguous":
|
|
62
|
+
lines.push(`Several vault keys match that query. Pick one and re-run:`);
|
|
63
|
+
for (const c of result.candidates ?? []) {
|
|
64
|
+
lines.push(` ${c}`);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (result.fixed && result.localPath) {
|
|
69
|
+
lines.push(`now local at ${result.localPath}`);
|
|
70
|
+
}
|
|
71
|
+
if (result.requestNote) {
|
|
72
|
+
lines.push(result.requestNote);
|
|
73
|
+
}
|
|
74
|
+
for (const step of result.steps) {
|
|
75
|
+
lines.push(` - ${step}`);
|
|
76
|
+
}
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
function normalizeTarget(raw, slug) {
|
|
80
|
+
let t = raw.trim();
|
|
81
|
+
if (t.startsWith("./"))
|
|
82
|
+
t = t.slice(2);
|
|
83
|
+
t = t.replace(/^\/+/, "");
|
|
84
|
+
const anchor = `companies/${slug}/`;
|
|
85
|
+
if (t.startsWith(anchor))
|
|
86
|
+
t = t.slice(anchor.length);
|
|
87
|
+
return t;
|
|
88
|
+
}
|
|
89
|
+
function httpStatusOf(err) {
|
|
90
|
+
if (!err || typeof err !== "object")
|
|
91
|
+
return undefined;
|
|
92
|
+
const rec = err;
|
|
93
|
+
if (typeof rec.status === "number")
|
|
94
|
+
return rec.status;
|
|
95
|
+
if (typeof rec.$metadata?.httpStatusCode === "number") {
|
|
96
|
+
return rec.$metadata.httpStatusCode;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* List under `prefix`. A 403 from the list route (`/v1/files/list`) means the
|
|
102
|
+
* caller cannot enumerate the prefix — surfaced as `{ denied: true }` so the
|
|
103
|
+
* caller maps it to outcome `no-access` (exit 3) instead of a bare error.
|
|
104
|
+
*/
|
|
105
|
+
async function listKeys(s3, bucket, prefix) {
|
|
106
|
+
try {
|
|
107
|
+
return { keys: await listKeysOrThrow(s3, bucket, prefix), denied: false };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (httpStatusOf(err) === 403)
|
|
111
|
+
return { keys: [], denied: true };
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function listKeysOrThrow(s3, bucket, prefix) {
|
|
116
|
+
const keys = [];
|
|
117
|
+
let continuationToken;
|
|
118
|
+
do {
|
|
119
|
+
const resp = (await s3.send(new ListObjectsV2Command({
|
|
120
|
+
Bucket: bucket,
|
|
121
|
+
Prefix: prefix,
|
|
122
|
+
ContinuationToken: continuationToken,
|
|
123
|
+
})));
|
|
124
|
+
for (const obj of resp.Contents ?? []) {
|
|
125
|
+
if (!obj.Key)
|
|
126
|
+
continue;
|
|
127
|
+
if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
|
|
128
|
+
continue;
|
|
129
|
+
keys.push(obj.Key);
|
|
130
|
+
}
|
|
131
|
+
continuationToken = resp.NextContinuationToken ?? undefined;
|
|
132
|
+
} while (continuationToken);
|
|
133
|
+
return keys;
|
|
134
|
+
}
|
|
135
|
+
function treeIsEmpty(tree) {
|
|
136
|
+
const noDirect = tree.directRow == null;
|
|
137
|
+
const noInherited = !tree.inherited || tree.inherited.length === 0;
|
|
138
|
+
const noChildren = !tree.children || tree.children.length === 0;
|
|
139
|
+
return noDirect && noInherited && noChildren;
|
|
140
|
+
}
|
|
141
|
+
function aclDenied(tree) {
|
|
142
|
+
if (!tree)
|
|
143
|
+
return false;
|
|
144
|
+
if (treeIsEmpty(tree))
|
|
145
|
+
return false;
|
|
146
|
+
const perm = tree.effectivePermission;
|
|
147
|
+
return perm === null || perm === "none" || perm === "deny";
|
|
148
|
+
}
|
|
149
|
+
function finish(outcome, fields) {
|
|
150
|
+
return { outcome, exitCode: outcomeExitCode(outcome), ...fields };
|
|
151
|
+
}
|
|
152
|
+
export function resolveGrantor(tree, members) {
|
|
153
|
+
const creatorUid = tree?.directRow?.creatorUid;
|
|
154
|
+
if (creatorUid) {
|
|
155
|
+
const creator = members.find((m) => m.personUid === creatorUid);
|
|
156
|
+
if (creator) {
|
|
157
|
+
return {
|
|
158
|
+
personUid: creator.personUid,
|
|
159
|
+
email: creator.personEmail,
|
|
160
|
+
name: creator.personName,
|
|
161
|
+
source: "acl-creator",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const owner = members.find((m) => m.role === "owner");
|
|
166
|
+
if (owner) {
|
|
167
|
+
return {
|
|
168
|
+
personUid: owner.personUid,
|
|
169
|
+
email: owner.personEmail,
|
|
170
|
+
name: owner.personName,
|
|
171
|
+
source: "owner",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const admin = members.find((m) => m.role === "admin");
|
|
175
|
+
if (admin) {
|
|
176
|
+
return {
|
|
177
|
+
personUid: admin.personUid,
|
|
178
|
+
email: admin.personEmail,
|
|
179
|
+
name: admin.personName,
|
|
180
|
+
source: "admin",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
function grantorLabel(g) {
|
|
186
|
+
const name = g.name ? `${g.name} ` : "";
|
|
187
|
+
return `${name}${g.email}`.trim();
|
|
188
|
+
}
|
|
189
|
+
export async function runAccess(input) {
|
|
190
|
+
const { deps, json } = input;
|
|
191
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
192
|
+
const stderr = deps.stderr ?? ((line) => console.error(line));
|
|
193
|
+
const slug = deps.companySlug;
|
|
194
|
+
const steps = [];
|
|
195
|
+
const relative = normalizeTarget(input.target, slug);
|
|
196
|
+
const anchored = (key) => `companies/${slug}/${key}`;
|
|
197
|
+
let vendDenied = false;
|
|
198
|
+
try {
|
|
199
|
+
await deps.vaultClient.sts.vend({ companyUid: deps.companyUid });
|
|
200
|
+
steps.push("vend: ok");
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
const status = httpStatusOf(err);
|
|
204
|
+
if (status === 403) {
|
|
205
|
+
vendDenied = true;
|
|
206
|
+
steps.push("vend: 403 (no-access)");
|
|
207
|
+
}
|
|
208
|
+
else if (status === 404) {
|
|
209
|
+
steps.push("vend: 404");
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const entity = await deps.vaultClient.entity.findInMyNamespace("company", slug);
|
|
216
|
+
const bucket = entity?.bucketName ?? `hq-vault-cmp-${slug}`;
|
|
217
|
+
const s3 = deps.companyClient({ companyUid: deps.companyUid });
|
|
218
|
+
let listDenied = false;
|
|
219
|
+
const prefixList = await listKeys(s3, bucket, relative);
|
|
220
|
+
const exact = prefixList.keys.find((k) => k === relative);
|
|
221
|
+
let key;
|
|
222
|
+
if (prefixList.denied) {
|
|
223
|
+
// The server refused to enumerate — the caller has no read on this
|
|
224
|
+
// prefix. Keep the target as the key so the request rung can still name
|
|
225
|
+
// it for the grantor.
|
|
226
|
+
listDenied = true;
|
|
227
|
+
key = relative;
|
|
228
|
+
steps.push("list: 403 (no-access)");
|
|
229
|
+
}
|
|
230
|
+
else if (exact) {
|
|
231
|
+
key = exact;
|
|
232
|
+
steps.push("resolve: exact key");
|
|
233
|
+
steps.push("list: 1 hit");
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const allList = await listKeys(s3, bucket, "");
|
|
237
|
+
const q = relative.toLowerCase();
|
|
238
|
+
// A root-list 403 only says the caller cannot enumerate the WHOLE bucket.
|
|
239
|
+
// The prefix list above succeeded, so fuzzy-match within it first; the
|
|
240
|
+
// root denial is the verdict only when the readable prefix has no match.
|
|
241
|
+
const pool = allList.denied ? prefixList.keys : allList.keys;
|
|
242
|
+
const fuzzy = pool.filter((k) => k.toLowerCase().includes(q));
|
|
243
|
+
if (allList.denied && fuzzy.length === 0) {
|
|
244
|
+
listDenied = true;
|
|
245
|
+
key = relative;
|
|
246
|
+
steps.push("list: 403 (no-access)");
|
|
247
|
+
}
|
|
248
|
+
else if (fuzzy.length === 0) {
|
|
249
|
+
steps.push("list: 0 hits");
|
|
250
|
+
const result = finish("never-existed", {
|
|
251
|
+
path: anchored(relative) || input.target,
|
|
252
|
+
company: slug,
|
|
253
|
+
exists: false,
|
|
254
|
+
steps,
|
|
255
|
+
});
|
|
256
|
+
emit(input, result, log);
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
else if (fuzzy.length === 1) {
|
|
260
|
+
key = fuzzy[0];
|
|
261
|
+
steps.push("resolve: fuzzy (1 hit)");
|
|
262
|
+
steps.push("list: 1 hit");
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const candidates = fuzzy.map(anchored);
|
|
266
|
+
steps.push(`list: ${fuzzy.length} hits`);
|
|
267
|
+
let picked = null;
|
|
268
|
+
if (deps.pick) {
|
|
269
|
+
picked = await deps.pick(candidates);
|
|
270
|
+
}
|
|
271
|
+
if (!picked) {
|
|
272
|
+
const result = finish("ambiguous", {
|
|
273
|
+
path: input.target,
|
|
274
|
+
company: slug,
|
|
275
|
+
exists: false,
|
|
276
|
+
candidates,
|
|
277
|
+
steps,
|
|
278
|
+
});
|
|
279
|
+
emit(input, result, log);
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
const stripped = normalizeTarget(picked, slug);
|
|
283
|
+
key = stripped;
|
|
284
|
+
steps.push("resolve: pick");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const resolvedKey = key;
|
|
288
|
+
const resolvedPath = anchored(resolvedKey);
|
|
289
|
+
const tree = await deps.fetchAclTree(resolvedKey);
|
|
290
|
+
let noAccess = vendDenied || listDenied;
|
|
291
|
+
if (tree == null || treeIsEmpty(tree)) {
|
|
292
|
+
steps.push("acl: no record (membership-distributed)");
|
|
293
|
+
}
|
|
294
|
+
else if (aclDenied(tree)) {
|
|
295
|
+
noAccess = true;
|
|
296
|
+
steps.push("acl: deny");
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
steps.push(`acl: ${tree.effectivePermission ?? "ok"}`);
|
|
300
|
+
}
|
|
301
|
+
if (vendDenied || listDenied) {
|
|
302
|
+
noAccess = true;
|
|
303
|
+
}
|
|
304
|
+
const onDisk = fs.existsSync(path.join(deps.hqRoot, "companies", slug, resolvedKey));
|
|
305
|
+
steps.push(onDisk ? "disk: present" : "disk: missing");
|
|
306
|
+
let outcome;
|
|
307
|
+
if (noAccess) {
|
|
308
|
+
outcome = "no-access";
|
|
309
|
+
}
|
|
310
|
+
else if (onDisk) {
|
|
311
|
+
outcome = "local";
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
outcome = "not-synced";
|
|
315
|
+
}
|
|
316
|
+
let fixed;
|
|
317
|
+
let localPath;
|
|
318
|
+
if (outcome === "not-synced" && input.fix === true && !noAccess) {
|
|
319
|
+
const absLocal = path.join(deps.hqRoot, "companies", slug, resolvedKey);
|
|
320
|
+
const getFn = deps.get ??
|
|
321
|
+
((anchoredPath) => runGet({
|
|
322
|
+
path: anchoredPath,
|
|
323
|
+
hqRoot: deps.hqRoot,
|
|
324
|
+
companySlug: slug,
|
|
325
|
+
vaultClient: deps.vaultClient,
|
|
326
|
+
companyClient: deps.companyClient,
|
|
327
|
+
region: deps.region,
|
|
328
|
+
}));
|
|
329
|
+
const statusFn = deps.syncStatus ??
|
|
330
|
+
(async () => {
|
|
331
|
+
const journals = listJournals();
|
|
332
|
+
if (journals.length === 0)
|
|
333
|
+
return "no sync journal yet";
|
|
334
|
+
let files = 0;
|
|
335
|
+
for (const j of journals) {
|
|
336
|
+
const entries = Object.entries(j.journal.files ?? {});
|
|
337
|
+
files += entries.filter(([, e]) => !e.removedAt).length;
|
|
338
|
+
}
|
|
339
|
+
return `${journals.length} journals, ${files} files tracked`;
|
|
340
|
+
});
|
|
341
|
+
const doctorFn = deps.syncDoctor ??
|
|
342
|
+
(async (apply) => {
|
|
343
|
+
const result = await syncDoctor({
|
|
344
|
+
entity: "local",
|
|
345
|
+
vaultConfig: {},
|
|
346
|
+
hqRoot: deps.hqRoot,
|
|
347
|
+
yes: apply,
|
|
348
|
+
reconcileConflicts: true,
|
|
349
|
+
});
|
|
350
|
+
const twins = result.conflictTwins?.plan?.length ?? 0;
|
|
351
|
+
const bulkRefused = Boolean(result.plan?.bulkRefused);
|
|
352
|
+
return {
|
|
353
|
+
twins,
|
|
354
|
+
summary: `${twins} conflict twins`,
|
|
355
|
+
bulkRefused,
|
|
356
|
+
breakerMessage: bulkRefused
|
|
357
|
+
? "bulk-asymmetry circuit-breaker tripped — refusing the delete leg"
|
|
358
|
+
: undefined,
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
const applyGetSuccess = (label) => {
|
|
362
|
+
steps.push(`${label}: ok ${absLocal}`);
|
|
363
|
+
outcome = "local";
|
|
364
|
+
fixed = true;
|
|
365
|
+
localPath = absLocal;
|
|
366
|
+
};
|
|
367
|
+
try {
|
|
368
|
+
await getFn(resolvedPath);
|
|
369
|
+
applyGetSuccess("get");
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
const status = httpStatusOf(err);
|
|
373
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
374
|
+
if (status === 403) {
|
|
375
|
+
steps.push(`get: failed ${message}`);
|
|
376
|
+
outcome = "no-access";
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
steps.push(`get: failed ${message}`);
|
|
380
|
+
const statusSummary = await statusFn();
|
|
381
|
+
steps.push(`sync status: ${statusSummary}`);
|
|
382
|
+
const dry = await doctorFn(false);
|
|
383
|
+
steps.push(`sync doctor (dry-run): ${dry.summary}`);
|
|
384
|
+
if (dry.bulkRefused) {
|
|
385
|
+
if (dry.breakerMessage) {
|
|
386
|
+
steps.push(dry.breakerMessage);
|
|
387
|
+
}
|
|
388
|
+
steps.push("sync doctor: circuit-breaker — aborting repair (never override; see policy hq-sync-bulk-asymmetry-breaker-means-abort)");
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
if (dry.twins > 0) {
|
|
392
|
+
const applied = await doctorFn(true);
|
|
393
|
+
steps.push(`sync doctor (apply): ${applied.summary}`);
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
await getFn(resolvedPath);
|
|
397
|
+
applyGetSuccess("get (retry)");
|
|
398
|
+
}
|
|
399
|
+
catch (retryErr) {
|
|
400
|
+
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
401
|
+
steps.push(`get (retry): failed ${retryMsg}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
let grantor;
|
|
408
|
+
let requestSentAt;
|
|
409
|
+
let alreadyAskedAt;
|
|
410
|
+
let requestNote;
|
|
411
|
+
if (outcome === "no-access" && input.fix === true) {
|
|
412
|
+
if (!deps.dm) {
|
|
413
|
+
steps.push("request: no dm transport");
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
const members = await deps.listMembers();
|
|
417
|
+
const resolved = resolveGrantor(tree, members);
|
|
418
|
+
if (!resolved) {
|
|
419
|
+
steps.push("request: no grantor found");
|
|
420
|
+
requestNote = "No grantor found. Contact a company owner.";
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
grantor = resolved;
|
|
424
|
+
const grantorLine = `Grantor: ${grantorLabel(resolved)} (${resolved.source})`;
|
|
425
|
+
// The recipient is always shown BEFORE any send. Under --json stdout is
|
|
426
|
+
// reserved for the single JSON object, so the line goes to stderr.
|
|
427
|
+
if (json)
|
|
428
|
+
stderr(grantorLine);
|
|
429
|
+
else
|
|
430
|
+
log(grantorLine);
|
|
431
|
+
const nowMs = (deps.now ?? Date.now)();
|
|
432
|
+
const recent = findRecentAccessRequest(deps.hqRoot, {
|
|
433
|
+
requester: deps.requester.email,
|
|
434
|
+
prefix: resolvedKey,
|
|
435
|
+
company: slug,
|
|
436
|
+
grantor: resolved.email,
|
|
437
|
+
now: nowMs,
|
|
438
|
+
});
|
|
439
|
+
if (recent) {
|
|
440
|
+
alreadyAskedAt = recent.sentAt;
|
|
441
|
+
const ago = formatTimeAgo(recent.sentAt, nowMs);
|
|
442
|
+
const who = resolved.name ?? resolved.email;
|
|
443
|
+
requestNote = `already asked ${who} ${ago}`;
|
|
444
|
+
steps.push(`request: already asked ${resolved.email}`);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const question = `Ask ${grantorLabel(resolved)} for read access to ${resolvedKey}?`;
|
|
448
|
+
const yes = input.yes === true;
|
|
449
|
+
let approved = false;
|
|
450
|
+
if (yes) {
|
|
451
|
+
approved = true;
|
|
452
|
+
}
|
|
453
|
+
else if (json) {
|
|
454
|
+
outcome = "pending-confirmation";
|
|
455
|
+
steps.push("request: pending confirmation");
|
|
456
|
+
}
|
|
457
|
+
else if (!deps.confirm) {
|
|
458
|
+
log(question);
|
|
459
|
+
log("Re-run with --yes to send.");
|
|
460
|
+
steps.push("request: confirmation unavailable");
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
approved = await deps.confirm(question);
|
|
464
|
+
if (!approved) {
|
|
465
|
+
requestNote = "not sent";
|
|
466
|
+
steps.push("request: declined");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (approved && outcome === "no-access") {
|
|
470
|
+
const message = `${deps.requester.email} is asking for read access to ${resolvedKey} in ${slug}.`;
|
|
471
|
+
const prompt = `hq files share ${resolvedKey} --with ${deps.requester.email} --permission read --company ${slug}`;
|
|
472
|
+
const sent = await deps.dm.send({
|
|
473
|
+
recipient: resolved.email,
|
|
474
|
+
message,
|
|
475
|
+
prompt,
|
|
476
|
+
});
|
|
477
|
+
const sentAt = new Date(nowMs).toISOString();
|
|
478
|
+
requestSentAt = sentAt;
|
|
479
|
+
try {
|
|
480
|
+
recordAccessRequest(deps.hqRoot, {
|
|
481
|
+
requester: deps.requester.email,
|
|
482
|
+
prefix: resolvedKey,
|
|
483
|
+
company: slug,
|
|
484
|
+
grantor: resolved.email,
|
|
485
|
+
sentAt,
|
|
486
|
+
eventId: sent.eventId,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
// The DM is already out. Never let a ledger failure hide that
|
|
491
|
+
// from the caller or crash the run after the side effect.
|
|
492
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
493
|
+
steps.push(`ledger: write failed after send — ${msg}`);
|
|
494
|
+
stderr(`Warning: the request was sent but could not be recorded (${msg}); the next run may ask again.`);
|
|
495
|
+
}
|
|
496
|
+
steps.push(`request: sent to ${resolved.email} (eventId ${sent.eventId})`);
|
|
497
|
+
const who = resolved.name ?? resolved.email;
|
|
498
|
+
requestNote = `asked ${who}, you will get a DM when granted`;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const result = finish(outcome, {
|
|
505
|
+
path: resolvedPath,
|
|
506
|
+
company: slug,
|
|
507
|
+
exists: true,
|
|
508
|
+
steps,
|
|
509
|
+
...(fixed ? { fixed, localPath } : {}),
|
|
510
|
+
...(grantor ? { grantor } : {}),
|
|
511
|
+
...(requestSentAt ? { requestSentAt } : {}),
|
|
512
|
+
...(alreadyAskedAt ? { alreadyAskedAt } : {}),
|
|
513
|
+
...(requestNote ? { requestNote } : {}),
|
|
514
|
+
});
|
|
515
|
+
emit(input, result, log);
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
function emit(input, result, log) {
|
|
519
|
+
if (input.json) {
|
|
520
|
+
const payload = {
|
|
521
|
+
outcome: result.outcome,
|
|
522
|
+
path: result.path,
|
|
523
|
+
company: result.company,
|
|
524
|
+
exists: result.exists,
|
|
525
|
+
steps: result.steps,
|
|
526
|
+
};
|
|
527
|
+
if (result.grantor)
|
|
528
|
+
payload.grantor = result.grantor;
|
|
529
|
+
if (result.candidates)
|
|
530
|
+
payload.candidates = result.candidates;
|
|
531
|
+
if (result.fixed)
|
|
532
|
+
payload.fixed = result.fixed;
|
|
533
|
+
if (result.localPath)
|
|
534
|
+
payload.localPath = result.localPath;
|
|
535
|
+
if (result.requestSentAt)
|
|
536
|
+
payload.requestSentAt = result.requestSentAt;
|
|
537
|
+
if (result.alreadyAskedAt)
|
|
538
|
+
payload.alreadyAskedAt = result.alreadyAskedAt;
|
|
539
|
+
log(JSON.stringify(payload, null, 2));
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
log(formatAccessOutcome(result));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function readActiveCompany(hqRoot) {
|
|
546
|
+
try {
|
|
547
|
+
const raw = fs.readFileSync(path.join(hqRoot, ".hq", "config.json"), "utf-8");
|
|
548
|
+
const cfg = JSON.parse(raw);
|
|
549
|
+
if (typeof cfg.activeCompany === "string" && cfg.activeCompany.length > 0) {
|
|
550
|
+
return cfg.activeCompany;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
/* missing */
|
|
555
|
+
}
|
|
556
|
+
return undefined;
|
|
557
|
+
}
|
|
558
|
+
function slugFromTarget(target) {
|
|
559
|
+
try {
|
|
560
|
+
return parseCompanySlugFromPath(target);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async function promptPick(candidates) {
|
|
567
|
+
if (!process.stdin.isTTY)
|
|
568
|
+
return null;
|
|
569
|
+
// Candidates are prompt UI, not output — keep them off stdout so --json
|
|
570
|
+
// stays a single parseable object.
|
|
571
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
572
|
+
console.error(` ${i + 1}. ${candidates[i]}`);
|
|
573
|
+
}
|
|
574
|
+
const rl = readline.createInterface({
|
|
575
|
+
input: process.stdin,
|
|
576
|
+
output: process.stderr,
|
|
577
|
+
});
|
|
578
|
+
const answer = await new Promise((resolve) => {
|
|
579
|
+
rl.question("Pick a number: ", (line) => {
|
|
580
|
+
rl.close();
|
|
581
|
+
resolve(line);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
const n = Number.parseInt(answer.trim(), 10);
|
|
585
|
+
if (!Number.isFinite(n) || n < 1 || n > candidates.length)
|
|
586
|
+
return null;
|
|
587
|
+
return candidates[n - 1] ?? null;
|
|
588
|
+
}
|
|
589
|
+
function treeFromBody(body) {
|
|
590
|
+
if (!body)
|
|
591
|
+
return null;
|
|
592
|
+
if (treeIsEmpty(body))
|
|
593
|
+
return null;
|
|
594
|
+
return body;
|
|
595
|
+
}
|
|
596
|
+
export function registerAccessCommand(program) {
|
|
597
|
+
program
|
|
598
|
+
.command("access <path-or-query>")
|
|
599
|
+
.description("Tell whether a vault file exists and whether you can read it. Never a bare not-found. Exit codes: 0 local/not-synced, 2 never-existed, 3 no-access, 4 ambiguous.")
|
|
600
|
+
.option("--company <slug>", "Company slug")
|
|
601
|
+
.option("--json", "Print a single JSON object")
|
|
602
|
+
.option("--no-fix", "Diagnose only: do not fetch, pin, or repair sync")
|
|
603
|
+
.option("--yes", "Answer the one send-confirmation yes (hosts must pass this explicitly)")
|
|
604
|
+
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
605
|
+
.addHelpText("after", `
|
|
606
|
+
Exit codes:
|
|
607
|
+
0 local or not-synced (file exists and you can read it)
|
|
608
|
+
2 never-existed (the path was never created)
|
|
609
|
+
3 no-access (the file exists but you cannot read it)
|
|
610
|
+
4 ambiguous (several keys matched; pick one)
|
|
611
|
+
`)
|
|
612
|
+
.action(async (target, opts) => {
|
|
613
|
+
try {
|
|
614
|
+
const result = await runAccessForPath(target, opts);
|
|
615
|
+
process.exitCode = result.exitCode;
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
619
|
+
console.error(chalk.red("Error:") + " " + message);
|
|
620
|
+
process.exitCode = 1;
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Resolve the company `hq access` runs against: explicit `company` (the
|
|
626
|
+
* ladder forwards the slug stamped on a 403), else the slug anchored in the
|
|
627
|
+
* target path, else the active company in `<hqRoot>/.hq/config.json`.
|
|
628
|
+
*/
|
|
629
|
+
export function resolveAccessCompany(target, opts) {
|
|
630
|
+
const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
631
|
+
return opts?.company ?? slugFromTarget(target) ?? readActiveCompany(hqRoot);
|
|
632
|
+
}
|
|
633
|
+
export async function runAccessForPath(path, opts) {
|
|
634
|
+
const target = path;
|
|
635
|
+
const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
636
|
+
const slug = resolveAccessCompany(target, opts);
|
|
637
|
+
if (!slug) {
|
|
638
|
+
throw new Error("Pass --company <slug>, an anchored companies/<slug>/... path, or set activeCompany in .hq/config.json.");
|
|
639
|
+
}
|
|
640
|
+
const { token, client } = await resolveBrowseSession();
|
|
641
|
+
const companyUid = await getCompanyUid(token, slug);
|
|
642
|
+
const fetchAclTree = async (prefix) => {
|
|
643
|
+
const res = await vaultApiFetch({
|
|
644
|
+
token,
|
|
645
|
+
path: `/files/${encodeURIComponent(companyUid)}/acl/tree`,
|
|
646
|
+
query: { prefix },
|
|
647
|
+
});
|
|
648
|
+
if (res.status === 404)
|
|
649
|
+
return null;
|
|
650
|
+
if (!res.ok) {
|
|
651
|
+
const body = (await res.json().catch(() => ({})));
|
|
652
|
+
throw new Error(body.message ?? body.error ?? `acl/tree failed (${res.status})`);
|
|
653
|
+
}
|
|
654
|
+
const tree = (await res.json());
|
|
655
|
+
return treeFromBody(tree);
|
|
656
|
+
};
|
|
657
|
+
const listMembers = async () => {
|
|
658
|
+
const rows = await listActiveMembers(token, companyUid);
|
|
659
|
+
return rows
|
|
660
|
+
.filter((m) => ["owner", "admin", "member", "guest"].includes(m.role))
|
|
661
|
+
.map((m) => ({
|
|
662
|
+
personUid: m.personUid,
|
|
663
|
+
personEmail: m.personEmail ?? "",
|
|
664
|
+
personName: m.personName,
|
|
665
|
+
role: m.role,
|
|
666
|
+
}));
|
|
667
|
+
};
|
|
668
|
+
const members = await listMembers();
|
|
669
|
+
const callerUid = await resolveCallerPersonUid(token);
|
|
670
|
+
const me = members.find((m) => m.personUid === callerUid);
|
|
671
|
+
let requesterEmail = me?.personEmail;
|
|
672
|
+
const requesterName = me?.personName;
|
|
673
|
+
if (!requesterEmail) {
|
|
674
|
+
const claims = peekIdToken(token);
|
|
675
|
+
const emailClaim = claims.email;
|
|
676
|
+
if (typeof emailClaim === "string" && emailClaim.length > 0) {
|
|
677
|
+
requesterEmail = emailClaim;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (!requesterEmail) {
|
|
681
|
+
throw new Error("Could not resolve your email; pass --company and ensure you are an active member");
|
|
682
|
+
}
|
|
683
|
+
const dm = {
|
|
684
|
+
async send(msg) {
|
|
685
|
+
const body = buildDmBody({
|
|
686
|
+
recipient: msg.recipient,
|
|
687
|
+
message: msg.message,
|
|
688
|
+
prompt: msg.prompt,
|
|
689
|
+
details: msg.details,
|
|
690
|
+
now: Date.now(),
|
|
691
|
+
});
|
|
692
|
+
const res = await vaultApiFetch({
|
|
693
|
+
token,
|
|
694
|
+
path: "/v1/notify/dm",
|
|
695
|
+
method: "POST",
|
|
696
|
+
body: body,
|
|
697
|
+
});
|
|
698
|
+
if (!res.ok) {
|
|
699
|
+
const errBody = (await res.json().catch(() => ({})));
|
|
700
|
+
throw new Error(errBody.message ?? errBody.error ?? `dm failed (${res.status})`);
|
|
701
|
+
}
|
|
702
|
+
const data = (await res.json());
|
|
703
|
+
return { eventId: data.eventId ?? "" };
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
const confirm = process.stdin.isTTY
|
|
707
|
+
? async (question) => {
|
|
708
|
+
const rl = readline.createInterface({
|
|
709
|
+
input: process.stdin,
|
|
710
|
+
output: process.stdout,
|
|
711
|
+
});
|
|
712
|
+
const answer = await new Promise((resolve) => {
|
|
713
|
+
rl.question(`${question} [y/N] `, (line) => {
|
|
714
|
+
rl.close();
|
|
715
|
+
resolve(line);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
const t = answer.trim().toLowerCase();
|
|
719
|
+
return t === "y" || t === "yes";
|
|
720
|
+
}
|
|
721
|
+
: undefined;
|
|
722
|
+
const result = await runAccess({
|
|
723
|
+
target,
|
|
724
|
+
fix: opts?.fix !== false,
|
|
725
|
+
json: Boolean(opts?.json),
|
|
726
|
+
yes: Boolean(opts?.yes),
|
|
727
|
+
deps: {
|
|
728
|
+
hqRoot,
|
|
729
|
+
companySlug: slug,
|
|
730
|
+
companyUid,
|
|
731
|
+
vaultClient: client,
|
|
732
|
+
companyClient: makeCompanyPresignFactory(token, slug),
|
|
733
|
+
region: "us-east-1",
|
|
734
|
+
fetchAclTree,
|
|
735
|
+
listMembers,
|
|
736
|
+
requester: { email: requesterEmail, name: requesterName },
|
|
737
|
+
dm,
|
|
738
|
+
confirm,
|
|
739
|
+
now: Date.now,
|
|
740
|
+
pick: promptPick,
|
|
741
|
+
get: (anchoredPath) => runGet({
|
|
742
|
+
path: anchoredPath,
|
|
743
|
+
hqRoot,
|
|
744
|
+
companySlug: slug,
|
|
745
|
+
vaultClient: client,
|
|
746
|
+
companyClient: makeCompanyPresignFactory(token, slug),
|
|
747
|
+
region: "us-east-1",
|
|
748
|
+
}),
|
|
749
|
+
syncStatus: async () => {
|
|
750
|
+
const journals = listJournals();
|
|
751
|
+
if (journals.length === 0)
|
|
752
|
+
return "no sync journal yet";
|
|
753
|
+
let files = 0;
|
|
754
|
+
for (const j of journals) {
|
|
755
|
+
const entries = Object.entries(j.journal.files ?? {});
|
|
756
|
+
files += entries.filter(([, e]) => !e.removedAt).length;
|
|
757
|
+
}
|
|
758
|
+
return `${journals.length} journals, ${files} files tracked`;
|
|
759
|
+
},
|
|
760
|
+
syncDoctor: async (apply) => {
|
|
761
|
+
const doctorResult = await syncDoctor({
|
|
762
|
+
entity: "local",
|
|
763
|
+
vaultConfig: {},
|
|
764
|
+
hqRoot,
|
|
765
|
+
yes: apply,
|
|
766
|
+
reconcileConflicts: true,
|
|
767
|
+
});
|
|
768
|
+
const twins = doctorResult.conflictTwins?.plan?.length ?? 0;
|
|
769
|
+
const bulkRefused = Boolean(doctorResult.plan?.bulkRefused);
|
|
770
|
+
return {
|
|
771
|
+
twins,
|
|
772
|
+
summary: `${twins} conflict twins`,
|
|
773
|
+
bulkRefused,
|
|
774
|
+
breakerMessage: bulkRefused
|
|
775
|
+
? "bulk-asymmetry circuit-breaker tripped — refusing the delete leg"
|
|
776
|
+
: undefined,
|
|
777
|
+
};
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
return result;
|
|
782
|
+
}
|
|
783
|
+
//# sourceMappingURL=access.js.map
|