@indigoai-us/hq-cli 5.17.0 → 5.18.1
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/dist/commands/cloud.d.ts +63 -1
- package/dist/commands/cloud.js +214 -11
- package/dist/commands/files-browse.d.ts +178 -0
- package/dist/commands/files-browse.js +348 -0
- package/dist/commands/files.d.ts +1 -1
- package/dist/commands/files.js +6 -2
- package/dist/commands/sync-mode.d.ts +115 -0
- package/dist/commands/sync-mode.js +249 -0
- package/dist/commands/sync-narrow.d.ts +154 -0
- package/dist/commands/sync-narrow.js +327 -0
- package/dist/index.js +11 -3
- package/dist/lib/local-tree-diff.d.ts +94 -0
- package/dist/lib/local-tree-diff.js +244 -0
- package/dist/lib/narrow-hint-banner.d.ts +102 -0
- package/dist/lib/narrow-hint-banner.js +144 -0
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +170 -1
- package/src/commands/cloud.pull-per-company.test.ts +188 -0
- package/src/commands/cloud.ts +327 -5
- package/src/commands/files-browse.test.ts +475 -0
- package/src/commands/files-browse.ts +561 -0
- package/src/commands/files.ts +6 -1
- package/src/commands/sync-mode.test.ts +366 -0
- package/src/commands/sync-mode.ts +387 -0
- package/src/commands/sync-narrow.test.ts +573 -0
- package/src/commands/sync-narrow.ts +541 -0
- package/src/index.ts +9 -1
- package/src/lib/hq-cloud-dep.smoke.test.ts +75 -0
- package/src/lib/local-tree-diff.test.ts +262 -0
- package/src/lib/local-tree-diff.ts +330 -0
- package/src/lib/narrow-hint-banner.test.ts +235 -0
- package/src/lib/narrow-hint-banner.ts +212 -0
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
15
|
import { Command } from "commander";
|
|
16
|
-
import { type ConflictStrategy } from "@indigoai-us/hq-cloud";
|
|
16
|
+
import { type ConflictStrategy, type MembershipSyncConfig } from "@indigoai-us/hq-cloud";
|
|
17
|
+
import { type BannerLevel } from "../lib/narrow-hint-banner.js";
|
|
17
18
|
export interface PullAllVaultClient {
|
|
18
19
|
listMyMemberships(): Promise<Array<{
|
|
19
20
|
companyUid: string;
|
|
21
|
+
membershipKey?: string;
|
|
20
22
|
}>>;
|
|
21
23
|
listPersonEntities(): Promise<Array<{
|
|
22
24
|
uid: string;
|
|
@@ -29,6 +31,13 @@ export interface PullAllVaultClient {
|
|
|
29
31
|
slug?: string;
|
|
30
32
|
name?: string;
|
|
31
33
|
} | null>;
|
|
34
|
+
/**
|
|
35
|
+
* US-011: optional — when present, `pullAll` calls it once per
|
|
36
|
+
* membership to surface the narrow-hint banner for all-mode owners.
|
|
37
|
+
* Absent on legacy adapters (push-all et al.) where the banner is not
|
|
38
|
+
* applicable.
|
|
39
|
+
*/
|
|
40
|
+
getMembershipSyncConfig?: (membershipId: string) => Promise<MembershipSyncConfig>;
|
|
32
41
|
}
|
|
33
42
|
export interface SyncCallOptions {
|
|
34
43
|
company: string;
|
|
@@ -52,6 +61,22 @@ export interface PullAllDeps {
|
|
|
52
61
|
export interface PullAllOptions {
|
|
53
62
|
hqRoot: string;
|
|
54
63
|
onConflict?: ConflictStrategy;
|
|
64
|
+
/**
|
|
65
|
+
* US-011: banner level for the narrow-hint nudge. Defaults to `'hint'`
|
|
66
|
+
* — see `resolveBannerLevel` for the env-driven override. The
|
|
67
|
+
* `'strict'` level causes `pullAll` to refuse to sync any membership
|
|
68
|
+
* still on `syncMode: 'all'` unless `modeAllOverride` is true.
|
|
69
|
+
*
|
|
70
|
+
* TODO(hq-core-staging release N+2): default flips to 'warning'.
|
|
71
|
+
* TODO(hq-core-staging release N+3): default flips to 'strict'.
|
|
72
|
+
*/
|
|
73
|
+
narrowHintLevel?: BannerLevel;
|
|
74
|
+
/**
|
|
75
|
+
* US-011: when `true`, strict-mode does NOT refuse all-mode
|
|
76
|
+
* memberships — the operator has explicitly opted into keeping the
|
|
77
|
+
* legacy behavior for this run via `--mode-all`.
|
|
78
|
+
*/
|
|
79
|
+
modeAllOverride?: boolean;
|
|
55
80
|
}
|
|
56
81
|
export interface PullAllRow {
|
|
57
82
|
slug: string;
|
|
@@ -138,5 +163,42 @@ export declare function assertSingleSelector(opts: {
|
|
|
138
163
|
personal?: boolean;
|
|
139
164
|
company?: string;
|
|
140
165
|
}, command: string): void;
|
|
166
|
+
/**
|
|
167
|
+
* Per-company pull resolution helper used by `hq sync pull --company <slug>`
|
|
168
|
+
* (US-011 fix, 2026-05-21). Mirrors the inline lookup that `runNowSingle`
|
|
169
|
+
* does for sync-now. Pulled out so the action handler stays thin AND so
|
|
170
|
+
* unit tests can exercise the banner / strict-refusal decision without
|
|
171
|
+
* spinning up commander + a real VaultClient.
|
|
172
|
+
*
|
|
173
|
+
* Input shape:
|
|
174
|
+
* - `targetCompany` — slug or UID the caller passed to `--company`. If
|
|
175
|
+
* undefined, the helper short-circuits to a "no resolution" result
|
|
176
|
+
* (the action handler falls back to .hq/config.json via sync()).
|
|
177
|
+
* - `client` — minimal VaultClient surface: listMyMemberships + entity.get
|
|
178
|
+
* + getMembershipSyncConfig.
|
|
179
|
+
*
|
|
180
|
+
* Output: `{ resolvedCompanyUid, resolvedMode }` — either may be undefined
|
|
181
|
+
* if the membership / sync-config call failed. Both undefined is a clean
|
|
182
|
+
* degradation — the caller pulls without a banner.
|
|
183
|
+
*/
|
|
184
|
+
export interface PerCompanyPullResolveClient {
|
|
185
|
+
listMyMemberships(): Promise<Array<{
|
|
186
|
+
companyUid: string;
|
|
187
|
+
membershipKey: string;
|
|
188
|
+
}>>;
|
|
189
|
+
getMembershipSyncConfig(membershipKey: string): Promise<{
|
|
190
|
+
syncMode: MembershipSyncConfig["syncMode"];
|
|
191
|
+
}>;
|
|
192
|
+
entity: {
|
|
193
|
+
get(uid: string): Promise<{
|
|
194
|
+
slug?: string;
|
|
195
|
+
}>;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export interface PerCompanyPullResolveResult {
|
|
199
|
+
resolvedCompanyUid: string | undefined;
|
|
200
|
+
resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
|
|
201
|
+
}
|
|
202
|
+
export declare function resolvePerCompanyPullPlan(client: PerCompanyPullResolveClient, targetCompany: string | undefined): Promise<PerCompanyPullResolveResult>;
|
|
141
203
|
export declare function registerCloudCommands(program: Command): void;
|
|
142
204
|
//# sourceMappingURL=cloud.d.ts.map
|
package/dist/commands/cloud.js
CHANGED
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
16
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="4672d875-1dc8-56a2-bef1-49c7f4ff6c76")}catch(e){}}();
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
20
20
|
import { share, sync, readJournal, getJournalPath, loadCachedTokens, VaultClient, computePersonalVaultPaths, } from "@indigoai-us/hq-cloud";
|
|
21
21
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
22
|
+
import { emitNarrowHint, isStrictRefusal, resolveBannerLevel, } from "../lib/narrow-hint-banner.js";
|
|
22
23
|
// Oldest-first by createdAt, ties broken by uid lexicographic — matches
|
|
23
24
|
// `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
|
|
24
25
|
// the same person bucket that `hq-sync-runner` picks.
|
|
@@ -35,6 +36,8 @@ function pickCanonicalPerson(persons) {
|
|
|
35
36
|
export async function pullAll(options, deps) {
|
|
36
37
|
const memberships = await deps.vaultClient.listMyMemberships();
|
|
37
38
|
const persons = await deps.vaultClient.listPersonEntities();
|
|
39
|
+
const narrowHintLevel = options.narrowHintLevel ?? "hint";
|
|
40
|
+
const getSyncConfig = deps.vaultClient.getMembershipSyncConfig;
|
|
38
41
|
const plan = [];
|
|
39
42
|
for (const m of memberships) {
|
|
40
43
|
let slug = m.companyUid;
|
|
@@ -48,6 +51,8 @@ export async function pullAll(options, deps) {
|
|
|
48
51
|
}
|
|
49
52
|
plan.push({
|
|
50
53
|
slug,
|
|
54
|
+
companyUid: m.companyUid,
|
|
55
|
+
...(m.membershipKey ? { membershipKey: m.membershipKey } : {}),
|
|
51
56
|
syncOptions: {
|
|
52
57
|
company: m.companyUid,
|
|
53
58
|
hqRoot: options.hqRoot,
|
|
@@ -78,12 +83,57 @@ export async function pullAll(options, deps) {
|
|
|
78
83
|
};
|
|
79
84
|
for (const entry of plan) {
|
|
80
85
|
result.attempted += 1;
|
|
86
|
+
// US-011: resolve the membership's effective sync mode so we can
|
|
87
|
+
// either nudge an all-mode owner toward `hq sync narrow` OR refuse
|
|
88
|
+
// the leg outright when strict-mode is on and the operator didn't
|
|
89
|
+
// pass `--mode-all`. Sync-config lookup is best-effort — a 404 or
|
|
90
|
+
// network blip should never block the sync itself, so we fall back
|
|
91
|
+
// to syncMode='all' (the legacy default) and skip the banner.
|
|
92
|
+
let resolvedMode;
|
|
93
|
+
if (entry.membershipKey && getSyncConfig) {
|
|
94
|
+
try {
|
|
95
|
+
const cfg = await getSyncConfig(entry.membershipKey);
|
|
96
|
+
resolvedMode = cfg.syncMode;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
resolvedMode = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (resolvedMode === "all" &&
|
|
103
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
104
|
+
!options.modeAllOverride &&
|
|
105
|
+
entry.companyUid) {
|
|
106
|
+
// Emit the strict-level banner once, then mark the leg as errored
|
|
107
|
+
// without invoking sync(). The operator either narrows the
|
|
108
|
+
// membership (`hq sync narrow --apply`) or passes `--mode-all` to
|
|
109
|
+
// opt back in.
|
|
110
|
+
emitNarrowHint({
|
|
111
|
+
companyUid: entry.companyUid,
|
|
112
|
+
syncMode: resolvedMode,
|
|
113
|
+
level: narrowHintLevel,
|
|
114
|
+
});
|
|
115
|
+
const message = "Refusing to pull all-mode membership in strict mode. " +
|
|
116
|
+
"Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
|
|
117
|
+
result.errors.push({ company: entry.slug, message });
|
|
118
|
+
result.perCompany.push({ slug: entry.slug, error: message });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
81
121
|
try {
|
|
82
122
|
const r = await deps.sync(entry.syncOptions);
|
|
83
123
|
result.filesDownloaded += r.filesDownloaded;
|
|
84
124
|
result.bytesDownloaded += r.bytesDownloaded;
|
|
85
125
|
result.conflicts += r.conflicts;
|
|
86
126
|
result.perCompany.push({ slug: entry.slug, result: r });
|
|
127
|
+
// Banner emitted AFTER the leg succeeds so it appears alongside
|
|
128
|
+
// the per-company summary line and doesn't get scrolled off by
|
|
129
|
+
// sync chatter.
|
|
130
|
+
if (resolvedMode === "all" && entry.companyUid) {
|
|
131
|
+
emitNarrowHint({
|
|
132
|
+
companyUid: entry.companyUid,
|
|
133
|
+
syncMode: resolvedMode,
|
|
134
|
+
level: narrowHintLevel,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
87
137
|
}
|
|
88
138
|
catch (err) {
|
|
89
139
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -202,6 +252,54 @@ export function assertSingleSelector(opts, command) {
|
|
|
202
252
|
`--company; got: ${selectors.join(", ")}.`);
|
|
203
253
|
}
|
|
204
254
|
}
|
|
255
|
+
export async function resolvePerCompanyPullPlan(client, targetCompany) {
|
|
256
|
+
if (!targetCompany)
|
|
257
|
+
return { resolvedCompanyUid: undefined, resolvedMode: undefined };
|
|
258
|
+
try {
|
|
259
|
+
const memberships = await client.listMyMemberships();
|
|
260
|
+
// Direct UID / membershipKey match first (cheapest).
|
|
261
|
+
const direct = memberships.find((m) => m.companyUid === targetCompany || m.membershipKey === targetCompany);
|
|
262
|
+
if (direct) {
|
|
263
|
+
let mode;
|
|
264
|
+
try {
|
|
265
|
+
const cfg = await client.getMembershipSyncConfig(direct.membershipKey);
|
|
266
|
+
mode = cfg.syncMode;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
mode = undefined;
|
|
270
|
+
}
|
|
271
|
+
return { resolvedCompanyUid: direct.companyUid, resolvedMode: mode };
|
|
272
|
+
}
|
|
273
|
+
// Slug match — listMyMemberships returns companyUid only, so fan out
|
|
274
|
+
// entity.get to find the row whose slug matches the caller's input.
|
|
275
|
+
for (const m of memberships) {
|
|
276
|
+
try {
|
|
277
|
+
const entity = await client.entity.get(m.companyUid);
|
|
278
|
+
if (entity.slug === targetCompany) {
|
|
279
|
+
let mode;
|
|
280
|
+
try {
|
|
281
|
+
const cfg = await client.getMembershipSyncConfig(m.membershipKey);
|
|
282
|
+
mode = cfg.syncMode;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
mode = undefined;
|
|
286
|
+
}
|
|
287
|
+
return { resolvedCompanyUid: m.companyUid, resolvedMode: mode };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Entity not visible — skip and continue. Worst case the loop ends
|
|
292
|
+
// with no match and we return undefined for both — the pull still
|
|
293
|
+
// proceeds, banner just stays quiet.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// listMyMemberships failed — degrade silently. Sync still works without
|
|
299
|
+
// the banner; this matches the runPullAll catch behavior.
|
|
300
|
+
}
|
|
301
|
+
return { resolvedCompanyUid: undefined, resolvedMode: undefined };
|
|
302
|
+
}
|
|
205
303
|
export function registerCloudCommands(program) {
|
|
206
304
|
program
|
|
207
305
|
.command("push")
|
|
@@ -407,6 +505,10 @@ export function registerCloudCommands(program) {
|
|
|
407
505
|
"(no companies/<slug>/ prefix). Resolves the person UID automatically " +
|
|
408
506
|
"from the cached Cognito session. Mutually exclusive with --company " +
|
|
409
507
|
"and --all.")
|
|
508
|
+
.option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
509
|
+
"Has no effect today (default narrow-hint level is 'hint'); " +
|
|
510
|
+
"wired so future hq-core-staging releases can flip the default to " +
|
|
511
|
+
"'strict' without re-touching this command.")
|
|
410
512
|
.action(async (options) => {
|
|
411
513
|
try {
|
|
412
514
|
assertSingleSelector(options, "pull");
|
|
@@ -416,7 +518,7 @@ export function registerCloudCommands(program) {
|
|
|
416
518
|
process.exit(1);
|
|
417
519
|
}
|
|
418
520
|
if (options.all) {
|
|
419
|
-
await runPullAll(options.hqRoot, options.onConflict);
|
|
521
|
+
await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true);
|
|
420
522
|
return;
|
|
421
523
|
}
|
|
422
524
|
if (options.personal) {
|
|
@@ -428,10 +530,37 @@ export function registerCloudCommands(program) {
|
|
|
428
530
|
console.log(` HQ root: ${options.hqRoot}`);
|
|
429
531
|
console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
|
|
430
532
|
const accessToken = await ensureCognitoToken();
|
|
533
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
534
|
+
// US-011 (2026-05-21 fix): resolve the caller's sync-config for
|
|
535
|
+
// the targeted membership BEFORE the pull, so we can (a) emit the
|
|
536
|
+
// narrow-hint banner after success if still on all-mode and
|
|
537
|
+
// (b) respect strict-mode refusal mirror of the --all + sync-now
|
|
538
|
+
// paths. Failure to resolve degrades silently — pull still works,
|
|
539
|
+
// banner just stays quiet (same as the catch in runPullAll).
|
|
540
|
+
const narrowHintLevel = resolveBannerLevel();
|
|
541
|
+
const { resolvedCompanyUid, resolvedMode } = await resolvePerCompanyPullPlan(new VaultClient(vaultConfig), options.company);
|
|
542
|
+
// Strict-mode refusal: matches runPullAll + runNowSingle behavior.
|
|
543
|
+
// Default banner level is 'hint' which never triggers refusal —
|
|
544
|
+
// wired now so future hq-core-staging releases can flip the
|
|
545
|
+
// default to 'strict' without re-touching this command.
|
|
546
|
+
if (resolvedMode === "all" &&
|
|
547
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
548
|
+
options.modeAll !== true &&
|
|
549
|
+
resolvedCompanyUid) {
|
|
550
|
+
emitNarrowHint({
|
|
551
|
+
companyUid: resolvedCompanyUid,
|
|
552
|
+
syncMode: resolvedMode,
|
|
553
|
+
level: narrowHintLevel,
|
|
554
|
+
});
|
|
555
|
+
console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
|
|
556
|
+
"membership still pulls everything. Run `hq sync narrow --apply` " +
|
|
557
|
+
"to migrate, or re-run with --mode-all."));
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
431
560
|
const result = await sync({
|
|
432
561
|
company: options.company,
|
|
433
562
|
onConflict: options.onConflict,
|
|
434
|
-
vaultConfig
|
|
563
|
+
vaultConfig,
|
|
435
564
|
hqRoot: options.hqRoot,
|
|
436
565
|
});
|
|
437
566
|
if (result.aborted) {
|
|
@@ -439,6 +568,16 @@ export function registerCloudCommands(program) {
|
|
|
439
568
|
process.exit(1);
|
|
440
569
|
}
|
|
441
570
|
console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
571
|
+
// US-011 (2026-05-21 fix): emit the hint banner after success
|
|
572
|
+
// so it appears alongside the summary line. Mirrors the wiring
|
|
573
|
+
// in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
|
|
574
|
+
if (resolvedMode === "all" && resolvedCompanyUid) {
|
|
575
|
+
emitNarrowHint({
|
|
576
|
+
companyUid: resolvedCompanyUid,
|
|
577
|
+
syncMode: resolvedMode,
|
|
578
|
+
level: narrowHintLevel,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
442
581
|
}
|
|
443
582
|
catch (err) {
|
|
444
583
|
console.error(chalk.red("\n✗ Pull failed:"), err instanceof Error ? err.message : String(err));
|
|
@@ -502,14 +641,17 @@ export function registerCloudCommands(program) {
|
|
|
502
641
|
"--personal.")
|
|
503
642
|
.option("--personal", "Sync the caller's canonical personal vault bidirectionally. " +
|
|
504
643
|
"Mutually exclusive with --company and --all.")
|
|
644
|
+
.option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
645
|
+
"No-op today; wired so future hq-core-staging releases can flip " +
|
|
646
|
+
"the default narrow-hint level to 'strict'.")
|
|
505
647
|
.action(async (options) => {
|
|
506
648
|
try {
|
|
507
649
|
assertSingleSelector(options, "now");
|
|
508
650
|
if (options.all) {
|
|
509
|
-
await runNowAll(options.hqRoot, options.message, options.onConflict);
|
|
651
|
+
await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true);
|
|
510
652
|
return;
|
|
511
653
|
}
|
|
512
|
-
await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict);
|
|
654
|
+
await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true);
|
|
513
655
|
}
|
|
514
656
|
catch (err) {
|
|
515
657
|
console.error(chalk.red("\n✗ Sync now failed:"), err instanceof Error ? err.message : String(err));
|
|
@@ -517,7 +659,7 @@ export function registerCloudCommands(program) {
|
|
|
517
659
|
}
|
|
518
660
|
});
|
|
519
661
|
}
|
|
520
|
-
async function runPullAll(hqRoot, onConflict) {
|
|
662
|
+
async function runPullAll(hqRoot, onConflict, modeAllOverride) {
|
|
521
663
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
522
664
|
console.log(` HQ root: ${hqRoot}`);
|
|
523
665
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
@@ -537,8 +679,14 @@ async function runPullAll(hqRoot, onConflict) {
|
|
|
537
679
|
return null;
|
|
538
680
|
}
|
|
539
681
|
},
|
|
682
|
+
getMembershipSyncConfig: (id) => realClient.getMembershipSyncConfig(id),
|
|
540
683
|
};
|
|
541
|
-
result = await pullAll({
|
|
684
|
+
result = await pullAll({
|
|
685
|
+
hqRoot,
|
|
686
|
+
...(onConflict ? { onConflict } : {}),
|
|
687
|
+
narrowHintLevel: resolveBannerLevel(),
|
|
688
|
+
...(modeAllOverride ? { modeAllOverride: true } : {}),
|
|
689
|
+
}, {
|
|
542
690
|
vaultClient: adapter,
|
|
543
691
|
sync: (opts) => sync({
|
|
544
692
|
company: opts.company,
|
|
@@ -687,7 +835,7 @@ async function runPushAll(hqRoot, message, onConflict) {
|
|
|
687
835
|
if (errored > 0)
|
|
688
836
|
process.exit(1);
|
|
689
837
|
}
|
|
690
|
-
async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
838
|
+
async function runNowSingle(hqRoot, company, personal, message, onConflict, modeAllOverride) {
|
|
691
839
|
console.log(chalk.bold("\nHQ Sync — Now"));
|
|
692
840
|
console.log(` HQ root: ${hqRoot}`);
|
|
693
841
|
console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
|
|
@@ -752,6 +900,49 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
|
752
900
|
console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
|
|
753
901
|
process.exit(1);
|
|
754
902
|
}
|
|
903
|
+
// US-011: resolve membership sync-config so we can either nudge an
|
|
904
|
+
// all-mode owner or refuse the pull when strict-mode is on. Skipped
|
|
905
|
+
// for personal targets (personal vault has no membership row) and
|
|
906
|
+
// for resolution failures (best-effort — never block sync). The
|
|
907
|
+
// lookup runs BEFORE the pull leg so strict refusal can short-circuit
|
|
908
|
+
// without burning a sync.
|
|
909
|
+
const narrowHintLevel = resolveBannerLevel();
|
|
910
|
+
let resolvedMode;
|
|
911
|
+
let resolvedCompanyUid;
|
|
912
|
+
if (!personalMode && targetCompany) {
|
|
913
|
+
try {
|
|
914
|
+
const client = new VaultClient(vaultConfig);
|
|
915
|
+
const memberships = await client.listMyMemberships();
|
|
916
|
+
const match = memberships.find((m) => m.companyUid === targetCompany || m.membershipKey === targetCompany);
|
|
917
|
+
if (match) {
|
|
918
|
+
resolvedCompanyUid = match.companyUid;
|
|
919
|
+
try {
|
|
920
|
+
const cfg = await client.getMembershipSyncConfig(match.membershipKey);
|
|
921
|
+
resolvedMode = cfg.syncMode;
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
resolvedMode = undefined;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
resolvedMode = undefined;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (resolvedMode === "all" &&
|
|
933
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
934
|
+
!modeAllOverride &&
|
|
935
|
+
resolvedCompanyUid) {
|
|
936
|
+
emitNarrowHint({
|
|
937
|
+
companyUid: resolvedCompanyUid,
|
|
938
|
+
syncMode: resolvedMode,
|
|
939
|
+
level: narrowHintLevel,
|
|
940
|
+
});
|
|
941
|
+
console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
|
|
942
|
+
"membership still pulls everything. Run `hq sync narrow --apply` " +
|
|
943
|
+
"to migrate, or re-run with --mode-all."));
|
|
944
|
+
process.exit(1);
|
|
945
|
+
}
|
|
755
946
|
console.log(chalk.dim(" → pull leg"));
|
|
756
947
|
const pullResult = await sync({
|
|
757
948
|
company: targetCompany,
|
|
@@ -769,6 +960,15 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
|
769
960
|
console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
|
|
770
961
|
process.exit(1);
|
|
771
962
|
}
|
|
963
|
+
// US-011: emit the hint banner after a successful pull so it
|
|
964
|
+
// appears at the bottom of the summary rather than mid-stream.
|
|
965
|
+
if (resolvedMode === "all" && resolvedCompanyUid) {
|
|
966
|
+
emitNarrowHint({
|
|
967
|
+
companyUid: resolvedCompanyUid,
|
|
968
|
+
syncMode: resolvedMode,
|
|
969
|
+
level: narrowHintLevel,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
772
972
|
console.log(chalk.green("\n✓ Sync now complete"));
|
|
773
973
|
}
|
|
774
974
|
catch (err) {
|
|
@@ -776,7 +976,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
|
776
976
|
process.exit(1);
|
|
777
977
|
}
|
|
778
978
|
}
|
|
779
|
-
async function runNowAll(hqRoot, message, onConflict) {
|
|
979
|
+
async function runNowAll(hqRoot, message, onConflict, modeAllOverride) {
|
|
780
980
|
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
781
981
|
console.log(` HQ root: ${hqRoot}`);
|
|
782
982
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
@@ -786,7 +986,10 @@ async function runNowAll(hqRoot, message, onConflict) {
|
|
|
786
986
|
console.log(chalk.dim("→ push --all"));
|
|
787
987
|
await runPushAll(hqRoot, message, onConflict);
|
|
788
988
|
console.log(chalk.dim("\n→ pull --all"));
|
|
789
|
-
|
|
989
|
+
// US-011: forward --mode-all so the strict refusal applies to the
|
|
990
|
+
// pull leg (push doesn't need a narrow-hint — the narrow ritual is
|
|
991
|
+
// pull-side).
|
|
992
|
+
await runPullAll(hqRoot, onConflict, modeAllOverride);
|
|
790
993
|
}
|
|
791
994
|
/**
|
|
792
995
|
* Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
|
|
@@ -860,4 +1063,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
860
1063
|
}
|
|
861
1064
|
}
|
|
862
1065
|
//# sourceMappingURL=cloud.js.map
|
|
863
|
-
//# debugId=
|
|
1066
|
+
//# debugId=4672d875-1dc8-56a2-bef1-49c7f4ff6c76
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq files browse <path>` + `hq files cat <path> [--out <file>]` (US-008).
|
|
3
|
+
*
|
|
4
|
+
* Peek at a company's vault files **without** ever materialising them under
|
|
5
|
+
* `companies/{co}/` in the local HQ tree. Distinct from the sync path:
|
|
6
|
+
*
|
|
7
|
+
* - `browse` — `ListObjectsV2` under the given prefix, prints
|
|
8
|
+
* `{key, size, lastModified, aclSource}` rows. The
|
|
9
|
+
* `aclSource` hint distinguishes prefixes the caller can
|
|
10
|
+
* see via an EXPLICIT grant (`shared-with-you`) from
|
|
11
|
+
* prefixes they can see only because owner/admin
|
|
12
|
+
* role-bypass widened the vended policy (`role-bypass`).
|
|
13
|
+
* - `cat` — `GetObject`, stream the body to stdout. With `--out
|
|
14
|
+
* <file>` write the body to a path the user picked, but
|
|
15
|
+
* only after a bright-line guard refuses any destination
|
|
16
|
+
* inside `<hqRoot>/companies/` — that's the exact tree
|
|
17
|
+
* `hq sync` owns, and writing a peeked object there would
|
|
18
|
+
* silently re-import it into the sync envelope.
|
|
19
|
+
*
|
|
20
|
+
* Both subcommands vend via the new `purpose: 'browse'` path
|
|
21
|
+
* (`VaultClient.vend`) shipped in hq-cloud US-009. The server treats that
|
|
22
|
+
* purpose as the role-bypass-allowed surface — sync vends NEVER widen, so
|
|
23
|
+
* keeping browse on its own vend call is the acceptance-criteria-1
|
|
24
|
+
* separation we need.
|
|
25
|
+
*
|
|
26
|
+
* Cross-package note: depends on `VendInput`/`VendResult` + the
|
|
27
|
+
* `VaultClient.vend` method from hq-cloud US-009 (commit 2f790c5).
|
|
28
|
+
* hq-cli pins `@indigoai-us/hq-cloud` to `file:../hq-cloud` via
|
|
29
|
+
* `pnpm.overrides` until that release ships to npm.
|
|
30
|
+
*/
|
|
31
|
+
import { Command } from "commander";
|
|
32
|
+
import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
|
|
33
|
+
import { type VendResult, type ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
34
|
+
/**
|
|
35
|
+
* Subset of `VaultClient` this command actually uses — exposed so tests
|
|
36
|
+
* can stub vend + grants without standing up a real `VaultClient`.
|
|
37
|
+
*/
|
|
38
|
+
export interface FilesBrowseVaultClient {
|
|
39
|
+
vend(input: {
|
|
40
|
+
paths: string[];
|
|
41
|
+
operations: "read-only" | "read-write" | "staged-write";
|
|
42
|
+
purpose: "sync" | "browse";
|
|
43
|
+
duration?: number;
|
|
44
|
+
}): Promise<VendResult>;
|
|
45
|
+
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
46
|
+
entity: {
|
|
47
|
+
get(uid: string): Promise<{
|
|
48
|
+
uid: string;
|
|
49
|
+
slug: string;
|
|
50
|
+
name?: string;
|
|
51
|
+
bucketName?: string;
|
|
52
|
+
}>;
|
|
53
|
+
findInMyNamespace(type: string, slug: string): Promise<{
|
|
54
|
+
uid: string;
|
|
55
|
+
slug: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
bucketName?: string;
|
|
58
|
+
} | null>;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Subset of `S3Client` this command actually uses — for test stubs. */
|
|
62
|
+
export interface FilesBrowseS3Client {
|
|
63
|
+
send(cmd: ListObjectsV2Command): Promise<ListObjectsV2CommandOutput>;
|
|
64
|
+
send(cmd: GetObjectCommand): Promise<GetObjectCommandOutput>;
|
|
65
|
+
}
|
|
66
|
+
/** Factory for an S3 client given vended credentials. Injectable for tests. */
|
|
67
|
+
export type S3ClientFactory = (input: {
|
|
68
|
+
region: string;
|
|
69
|
+
credentials: {
|
|
70
|
+
accessKeyId: string;
|
|
71
|
+
secretAccessKey: string;
|
|
72
|
+
sessionToken: string;
|
|
73
|
+
};
|
|
74
|
+
}) => FilesBrowseS3Client;
|
|
75
|
+
/** ACL provenance for a single listed key. */
|
|
76
|
+
export type AclSource = "shared-with-you" | "role-bypass";
|
|
77
|
+
export interface BrowseRow {
|
|
78
|
+
key: string;
|
|
79
|
+
size: number;
|
|
80
|
+
lastModified: Date | undefined;
|
|
81
|
+
aclSource: AclSource;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Parse the company slug from a vault prefix. Vault paths are anchored at
|
|
85
|
+
* `companies/<slug>/...`; anything else is rejected so we never try to
|
|
86
|
+
* browse a non-company tree (e.g. `personal/`) with a company-vend.
|
|
87
|
+
*/
|
|
88
|
+
export declare function parseCompanySlugFromPath(prefix: string): string;
|
|
89
|
+
/**
|
|
90
|
+
* Classify a single S3 key against the caller's explicit-grant list. Any
|
|
91
|
+
* grant whose `path` is a prefix of the key contributes `shared-with-you`;
|
|
92
|
+
* otherwise the key is only visible via role-bypass on the vend call.
|
|
93
|
+
*
|
|
94
|
+
* Grant paths and S3 keys live in the same canonical form ("companies/<slug>/…");
|
|
95
|
+
* `coalescePrefixes` would shrink the list further but isn't required for
|
|
96
|
+
* correctness — `startsWith` already short-circuits on the first match.
|
|
97
|
+
*/
|
|
98
|
+
export declare function classifyAclSource(key: string, grants: ExplicitGrant[]): AclSource;
|
|
99
|
+
/**
|
|
100
|
+
* Bright-line guard for `--out`: refuse to write any byte beneath
|
|
101
|
+
* `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
|
|
102
|
+
* slug-by-slug — `companies/` is the entire surface hq-sync owns, so a
|
|
103
|
+
* containment check on that parent suffices and avoids drift with the
|
|
104
|
+
* manifest file. Returns the resolved absolute output path on success;
|
|
105
|
+
* throws when the destination would land inside the protected tree.
|
|
106
|
+
*/
|
|
107
|
+
export declare function assertOutPathOutsideCompanies(outPath: string, hqRoot: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* Render a browse listing as a padded table. Mirrors the chalk + padEnd
|
|
110
|
+
* pattern used by `hq sync mode --show` so the CLI surface stays
|
|
111
|
+
* stylistically consistent.
|
|
112
|
+
*/
|
|
113
|
+
export declare function formatBrowseTable(rows: BrowseRow[]): string;
|
|
114
|
+
export interface RunBrowseInput {
|
|
115
|
+
/** Vault path prefix, e.g. `companies/indigo/scratch/`. */
|
|
116
|
+
pathPrefix: string;
|
|
117
|
+
/** Caller-overridden company slug (defaults to slug parsed from path). */
|
|
118
|
+
companySlug?: string;
|
|
119
|
+
vaultClient: FilesBrowseVaultClient;
|
|
120
|
+
s3Factory: S3ClientFactory;
|
|
121
|
+
region: string;
|
|
122
|
+
}
|
|
123
|
+
export interface RunBrowseResult {
|
|
124
|
+
rows: BrowseRow[];
|
|
125
|
+
vend: VendResult;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* `hq files browse <path>` orchestrator.
|
|
129
|
+
*
|
|
130
|
+
* 1. Parse slug from prefix (or use override).
|
|
131
|
+
* 2. Resolve companyUid + bucketName via VaultClient.entity.
|
|
132
|
+
* 3. Vend with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
|
|
133
|
+
* 4. Construct S3Client from vended creds, paginate ListObjectsV2.
|
|
134
|
+
* 5. Fetch explicit grants once, classify each key.
|
|
135
|
+
*
|
|
136
|
+
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
137
|
+
*/
|
|
138
|
+
export declare function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>;
|
|
139
|
+
export interface RunCatInput {
|
|
140
|
+
/** Single vault key, e.g. `companies/indigo/scratch/foo.txt`. */
|
|
141
|
+
key: string;
|
|
142
|
+
/**
|
|
143
|
+
* Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
|
|
144
|
+
* against `<hqRoot>/companies/` by `assertOutPathOutsideCompanies`.
|
|
145
|
+
*/
|
|
146
|
+
out?: string;
|
|
147
|
+
hqRoot: string;
|
|
148
|
+
companySlug?: string;
|
|
149
|
+
vaultClient: FilesBrowseVaultClient;
|
|
150
|
+
s3Factory: S3ClientFactory;
|
|
151
|
+
region: string;
|
|
152
|
+
/** Destination stream for the stdout path. Injectable for tests. */
|
|
153
|
+
stdout?: NodeJS.WritableStream;
|
|
154
|
+
}
|
|
155
|
+
export interface RunCatResult {
|
|
156
|
+
bytesWritten: number;
|
|
157
|
+
destination: {
|
|
158
|
+
kind: "stdout";
|
|
159
|
+
} | {
|
|
160
|
+
kind: "file";
|
|
161
|
+
absPath: string;
|
|
162
|
+
};
|
|
163
|
+
vend: VendResult;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
|
|
167
|
+
* streams the object body either to stdout or to `--out` (after the
|
|
168
|
+
* containment guard). Refuses ahead of any I/O when `--out` is unsafe.
|
|
169
|
+
*/
|
|
170
|
+
export declare function runCat(input: RunCatInput): Promise<RunCatResult>;
|
|
171
|
+
/**
|
|
172
|
+
* Wire `hq files browse` + `hq files cat` onto an existing `files`
|
|
173
|
+
* Commander group. `registerFilesCommand` in files.ts builds the group
|
|
174
|
+
* and registers `share`/`unshare`/`acl`; this function appends the two
|
|
175
|
+
* new browse-vs-sync subcommands so they share the `--company` switch.
|
|
176
|
+
*/
|
|
177
|
+
export declare function registerFilesBrowseCommands(filesCmd: Command): void;
|
|
178
|
+
//# sourceMappingURL=files-browse.d.ts.map
|