@indigoai-us/hq-cli 5.79.0 → 5.80.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.
@@ -22,6 +22,11 @@ import { type BannerLevel } from "../lib/narrow-hint-banner.js";
22
22
  * succeed when files were dropped" guarantee unit-testable.
23
23
  */
24
24
  export declare function scopeExcludedWarning(count: number): string | null;
25
+ /**
26
+ * Optional detail line listing the effective write prefixes when a push
27
+ * dropped files — helps debug ACL vs scope-excluded mismatches (feedback_7e9c535e).
28
+ */
29
+ export declare function scopeExcludedPrefixDetail(prefixSet: readonly string[] | undefined): string | null;
25
30
  /**
26
31
  * Bound foreground waits for a watcher/manual sync that currently owns the
27
32
  * per-root operation lock. The cloud engine reads this environment value on
@@ -159,6 +164,8 @@ export interface ShareCallOptions {
159
164
  skipUnchanged?: boolean;
160
165
  propagateDeletes?: boolean;
161
166
  propagateDeletePolicy?: "owned-only" | "currency-gated" | "all";
167
+ /** DEV-1768 push parity: filter upload plan to granted ACL prefixes. */
168
+ prefixSet?: PullScope["prefixSet"];
162
169
  }
163
170
  export interface ShareCallResult {
164
171
  filesUploaded: number;
@@ -172,6 +179,8 @@ export interface ShareCallResult {
172
179
  export interface PushAllDeps {
173
180
  vaultClient: PullAllVaultClient;
174
181
  share: (options: ShareCallOptions) => Promise<ShareCallResult>;
182
+ /** DEV-1768: resolve membership push scope (same as pull). */
183
+ resolveScope?: (companyUid: string, slug: string) => Promise<PullScope>;
175
184
  }
176
185
  export interface PushAllOptions {
177
186
  hqRoot: string;
@@ -32,6 +32,16 @@ export function scopeExcludedWarning(count) {
32
32
  `They were skipped, not synced. Re-run with --json to list them, or ` +
33
33
  `ask an admin to grant you write on those paths.`);
34
34
  }
35
+ /**
36
+ * Optional detail line listing the effective write prefixes when a push
37
+ * dropped files — helps debug ACL vs scope-excluded mismatches (feedback_7e9c535e).
38
+ */
39
+ export function scopeExcludedPrefixDetail(prefixSet) {
40
+ if (!prefixSet || prefixSet.length === 0) {
41
+ return " Granted write prefixes: (none — shared-mode membership with no explicit grants)";
42
+ }
43
+ return ` Granted write prefixes: ${prefixSet.join(", ")}`;
44
+ }
35
45
  /**
36
46
  * Resolve the `propagateDeletePolicy` for share() calls.
37
47
  *
@@ -268,6 +278,7 @@ export async function pushAll(options, deps) {
268
278
  }
269
279
  plan.push({
270
280
  slug,
281
+ companyUid: m.companyUid,
271
282
  shareOptions: {
272
283
  company: m.companyUid,
273
284
  hqRoot: options.hqRoot,
@@ -284,6 +295,7 @@ export async function pushAll(options, deps) {
284
295
  if (personal) {
285
296
  plan.push({
286
297
  slug: "personal",
298
+ companyUid: personal.uid,
287
299
  shareOptions: {
288
300
  company: personal.uid,
289
301
  hqRoot: options.hqRoot,
@@ -309,6 +321,17 @@ export async function pushAll(options, deps) {
309
321
  };
310
322
  for (const entry of plan) {
311
323
  result.attempted += 1;
324
+ if (entry.slug !== "personal" && deps.resolveScope) {
325
+ try {
326
+ const scope = await deps.resolveScope(entry.companyUid, entry.slug);
327
+ if (scope.prefixSet !== undefined) {
328
+ entry.shareOptions.prefixSet = scope.prefixSet;
329
+ }
330
+ }
331
+ catch {
332
+ // Degrade to no explicit scope filter — share() treats undefined as full access.
333
+ }
334
+ }
312
335
  try {
313
336
  const r = await deps.share(entry.shareOptions);
314
337
  result.filesUploaded += r.filesUploaded;
@@ -582,6 +605,8 @@ export function registerCloudCommands(program) {
582
605
  // 2. default: vend via cached Cognito session (the human CLI path).
583
606
  let entityContext;
584
607
  let vaultConfig;
608
+ /** Vault API config for membership/grant scope lookups. Set even on the pre-vended `--creds-from-stdin` path — share() accepts entityContext OR vaultConfig, not both. */
609
+ let scopeVaultConfig;
585
610
  if (options.credsFromStdin) {
586
611
  if (process.stdin.isTTY) {
587
612
  throw new Error("--creds-from-stdin requires JSON on stdin, but stdin is a " +
@@ -595,17 +620,22 @@ export function registerCloudCommands(program) {
595
620
  catch (e) {
596
621
  throw new Error(`--creds-from-stdin: failed to parse stdin as JSON: ${e instanceof Error ? e.message : String(e)}`);
597
622
  }
623
+ // Upload creds are pre-vended in entityContext, but prefix filtering
624
+ // still needs vault-service membership/grant resolution.
625
+ const accessToken = await ensureCognitoToken();
626
+ scopeVaultConfig = buildVaultConfig(accessToken);
598
627
  }
599
628
  else {
600
629
  const accessToken = await ensureCognitoToken();
601
630
  vaultConfig = buildVaultConfig(accessToken);
631
+ scopeVaultConfig = vaultConfig;
602
632
  }
603
633
  // Resolve the target. For `--personal`, look up the caller's
604
634
  // canonical person entity and force personalMode + journalSlug so
605
635
  // share() lands files at hqRoot directly (no companies/<slug>/
606
636
  // prefix). For everything else, the company is whatever the user
607
637
  // passed or the active company from .hq/config.json.
608
- let targetCompany = options.company;
638
+ let targetCompany = options.company ?? entityContext?.uid;
609
639
  let personalMode = false;
610
640
  let journalSlug;
611
641
  if (options.personal) {
@@ -646,6 +676,12 @@ export function registerCloudCommands(program) {
646
676
  // idToken — pre-vended `--creds-from-stdin` paths still get author
647
677
  // attribution as long as the caller is logged in locally.
648
678
  const author = resolveUploadAuthorFromCache();
679
+ let pushPrefixSet;
680
+ if (!personalMode && scopeVaultConfig) {
681
+ const scopeClient = new VaultClient(scopeVaultConfig);
682
+ const scope = await resolveCliPullScope(scopeClient, targetCompany, options.hqRoot);
683
+ pushPrefixSet = scope?.prefixSet;
684
+ }
649
685
  const result = await share({
650
686
  paths: targetPaths,
651
687
  company: targetCompany,
@@ -658,6 +694,7 @@ export function registerCloudCommands(program) {
658
694
  ...(personalMode ? { personalMode: true } : {}),
659
695
  ...(journalSlug !== undefined ? { journalSlug } : {}),
660
696
  ...(author ? { author } : {}),
697
+ ...(pushPrefixSet !== undefined ? { prefixSet: pushPrefixSet } : {}),
661
698
  });
662
699
  if (jsonMode) {
663
700
  // Synthetic terminal event so subprocess consumers can read final
@@ -681,6 +718,9 @@ export function registerCloudCommands(program) {
681
718
  log(chalk.yellow(`\n⚠ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ` +
682
719
  `${result.filesSkipped} skipped, ${result.filesExcludedByScope} scope-excluded)`));
683
720
  log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)));
721
+ const prefixDetail = scopeExcludedPrefixDetail(pushPrefixSet);
722
+ if (prefixDetail)
723
+ log(chalk.yellow(prefixDetail));
684
724
  }
685
725
  else {
686
726
  log(chalk.green(`\n✓ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ${result.filesSkipped} skipped)`));
@@ -1091,6 +1131,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1091
1131
  ...(skipPersonal ? { skipPersonal: true } : {}),
1092
1132
  }, {
1093
1133
  vaultClient: adapter,
1134
+ resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
1094
1135
  share: (opts) => share({
1095
1136
  paths: opts.paths,
1096
1137
  company: opts.company,
@@ -1113,6 +1154,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1113
1154
  ...(opts.propagateDeletePolicy !== undefined
1114
1155
  ? { propagateDeletePolicy: opts.propagateDeletePolicy }
1115
1156
  : {}),
1157
+ ...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
1116
1158
  ...(author ? { author } : {}),
1117
1159
  }),
1118
1160
  });
@@ -1195,6 +1237,12 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1195
1237
  // Push first so the subsequent pull doesn't redownload files we were
1196
1238
  // about to broadcast (matches hq-sync-runner ordering).
1197
1239
  console.log(chalk.dim(" → push leg"));
1240
+ let nowPushPrefixSet;
1241
+ if (!personalMode) {
1242
+ const scopeClient = new VaultClient(vaultConfig);
1243
+ const pushScope = await resolveCliPullScope(scopeClient, targetCompany, hqRoot);
1244
+ nowPushPrefixSet = pushScope?.prefixSet;
1245
+ }
1198
1246
  const pushResult = await share({
1199
1247
  paths: pushPaths,
1200
1248
  company: targetCompany,
@@ -1208,6 +1256,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1208
1256
  ...(personalMode ? { personalMode: true } : {}),
1209
1257
  ...(journalSlug !== undefined ? { journalSlug } : {}),
1210
1258
  ...(author ? { author } : {}),
1259
+ ...(nowPushPrefixSet !== undefined ? { prefixSet: nowPushPrefixSet } : {}),
1211
1260
  });
1212
1261
  const pushStatus = pushResult.aborted || pushResult.filesExcludedByScope > 0
1213
1262
  ? chalk.yellow("⚠")
@@ -1218,6 +1267,9 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1218
1267
  (pushResult.aborted ? " — aborted" : ""));
1219
1268
  if (pushResult.filesExcludedByScope > 0) {
1220
1269
  console.log(chalk.yellow(scopeExcludedWarning(pushResult.filesExcludedByScope)));
1270
+ const prefixDetail = scopeExcludedPrefixDetail(nowPushPrefixSet);
1271
+ if (prefixDetail)
1272
+ console.log(chalk.yellow(prefixDetail));
1221
1273
  }
1222
1274
  if (pushResult.aborted) {
1223
1275
  console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
@@ -78,7 +78,7 @@ export function buildInstalledView(hqRoot, hqVersion, pack, installedSources, ch
78
78
  const counts = { live: 0, broken: 0, missing: 0, foreign: 0 };
79
79
  const brokenLinks = [];
80
80
  for (const link of links) {
81
- const st = linkStatus(link);
81
+ const st = linkStatus(link, pack.dir);
82
82
  counts[st]++;
83
83
  if (st === 'broken')
84
84
  brokenLinks.push({ key: link.key, item: link.item, dst: link.dst });
package/dist/index.js CHANGED
@@ -11,8 +11,12 @@ if (isVersionRequest(process.argv)) {
11
11
  process.stdout.write(`${CLI_VERSION}\n`);
12
12
  }
13
13
  else {
14
- const { runCli } = await import("./main.js");
15
- await runCli();
14
+ // Keep the command lifecycle detached from module evaluation, as it was
15
+ // before the fast --version split. Some best-effort teardown work uses
16
+ // unref'd handles; top-level-awaiting runCli() makes Node turn an otherwise
17
+ // successful command into exit 13 when that teardown promise remains pending.
18
+ // Rejections still become unhandled and preserve a genuine non-zero failure.
19
+ void import("./main.js").then(({ runCli }) => runCli());
16
20
  }
17
21
  export const __test__ = { isVersionRequest };
18
22
  //# sourceMappingURL=index.js.map
@@ -75,8 +75,13 @@ export type LinkStatus = 'live' | 'broken' | 'missing' | 'foreign';
75
75
  * effect (a config merge) is handled separately (US-004/US-005).
76
76
  */
77
77
  export declare function contributionLinks(hqRoot: string, packDir: string, contributes: Partial<Record<PackContributeKey, string[]>>): WiredLink[];
78
+ /**
79
+ * Canonical absolute path — resolves symlinks and platform aliases such as
80
+ * macOS `/var` ↔ `/private/var` so equivalent paths compare equal.
81
+ */
82
+ export declare function canonicalPath(p: string): string;
78
83
  /** Classify a host path against the link that should own it. */
79
- export declare function linkStatus(link: WiredLink): LinkStatus;
84
+ export declare function linkStatus(link: WiredLink, packDir?: string): LinkStatus;
80
85
  /** A content pack's manifest plus the install-time stamped source. */
81
86
  export interface InstalledPackManifest extends PackManifest {
82
87
  source?: string;
@@ -132,8 +132,42 @@ export function contributionLinks(hqRoot, packDir, contributes) {
132
132
  }
133
133
  return links;
134
134
  }
135
+ /**
136
+ * Canonical absolute path — resolves symlinks and platform aliases such as
137
+ * macOS `/var` ↔ `/private/var` so equivalent paths compare equal.
138
+ */
139
+ export function canonicalPath(p) {
140
+ try {
141
+ return fs.realpathSync.native(p);
142
+ }
143
+ catch {
144
+ return path.resolve(p);
145
+ }
146
+ }
147
+ /** True when `candidate` resolves under `packDir` (pack directory containment). */
148
+ function targetUnderPackDir(packDir, candidate) {
149
+ const root = canonicalPath(packDir);
150
+ const resolved = canonicalPath(candidate);
151
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
152
+ return resolved === root || resolved.startsWith(prefix);
153
+ }
154
+ /** True when `a` and `b` resolve to the same on-disk file (canonical path or inode). */
155
+ function samePayloadFile(a, b) {
156
+ const canonA = canonicalPath(a);
157
+ const canonB = canonicalPath(b);
158
+ if (canonA === canonB)
159
+ return true;
160
+ try {
161
+ const stA = fs.statSync(a);
162
+ const stB = fs.statSync(b);
163
+ return stA.dev === stB.dev && stA.ino === stB.ino;
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ }
135
169
  /** Classify a host path against the link that should own it. */
136
- export function linkStatus(link) {
170
+ export function linkStatus(link, packDir) {
137
171
  let st;
138
172
  try {
139
173
  st = fs.lstatSync(link.dst);
@@ -151,13 +185,21 @@ export function linkStatus(link) {
151
185
  catch {
152
186
  return 'foreign';
153
187
  }
154
- // scan-packages.sh writes the symlink target as the absolute `src` path, so a
155
- // direct compare is correct. Resolve both to be robust to trailing slashes.
188
+ // scan-packages.sh writes the symlink target as the absolute `src` path.
189
+ // Canonicalize both sides so `/var` vs `/private/var` (macOS) and symlink
190
+ // aliases classify as ours; optionally accept stale targets that still resolve
191
+ // inside this pack's directory.
156
192
  const resolvedTarget = path.resolve(path.dirname(link.dst), target);
157
- if (path.resolve(link.src) !== resolvedTarget) {
158
- return 'foreign'; // points at another pack / somewhere else
193
+ if (samePayloadFile(link.src, resolvedTarget)) {
194
+ return fs.existsSync(link.src) ? 'live' : 'broken';
195
+ }
196
+ if (packDir !== undefined && targetUnderPackDir(packDir, resolvedTarget)) {
197
+ // Stale or corrupted: symlink resolves inside this pack but not to the
198
+ // declared payload. Treat as broken (never live) so list does not report a
199
+ // healthy link; unwirePack still removes it on uninstall.
200
+ return 'broken';
159
201
  }
160
- return fs.existsSync(link.src) ? 'live' : 'broken';
202
+ return 'foreign'; // points at another pack / somewhere else
161
203
  }
162
204
  /** Absolute path to `<hqRoot>/core/packages`. */
163
205
  export function packagesDir(hqRoot) {
@@ -230,7 +272,7 @@ export function findDependentPacks(installed, name) {
230
272
  export function unwirePack(hqRoot, packDir, contributes) {
231
273
  const result = { unlinked: [], skipped: [] };
232
274
  for (const link of contributionLinks(hqRoot, packDir, contributes)) {
233
- const status = linkStatus(link);
275
+ const status = linkStatus(link, packDir);
234
276
  if (status === 'live' || status === 'broken') {
235
277
  try {
236
278
  fs.unlinkSync(link.dst);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.79.0",
3
+ "version": "5.80.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
31
31
  "@aws-sdk/client-s3": "^3.1049.0",
32
- "@indigoai-us/hq-cloud": "^6.14.27",
32
+ "@indigoai-us/hq-cloud": "^6.14.37",
33
33
  "@indigoai-us/hq-onboarding": "^0.1.0",
34
34
  "@sentry/node": "^10.49.0",
35
35
  "better-sqlite3": "^12.11.1",