@indigoai-us/hq-cli 5.77.13 → 5.78.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 (307) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +51 -2
  33. package/dist/commands/pkg-install.js +43 -0
  34. package/dist/commands/run.js +8 -0
  35. package/dist/commands/secrets.js +125 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2247
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs.test.ts +0 -88
  146. package/src/commands/packs.ts +0 -622
  147. package/src/commands/people.test.ts +0 -807
  148. package/src/commands/people.ts +0 -422
  149. package/src/commands/pkg-install.dispatch.test.ts +0 -71
  150. package/src/commands/pkg-install.ts +0 -215
  151. package/src/commands/pkg-list.test.ts +0 -79
  152. package/src/commands/pkg-list.ts +0 -13
  153. package/src/commands/pkg-remove.ts +0 -71
  154. package/src/commands/pkg-update.ts +0 -189
  155. package/src/commands/publish.test.ts +0 -650
  156. package/src/commands/publish.ts +0 -580
  157. package/src/commands/reindex.test.ts +0 -259
  158. package/src/commands/reindex.ts +0 -274
  159. package/src/commands/rescue.test.ts +0 -39
  160. package/src/commands/rescue.ts +0 -210
  161. package/src/commands/run.env-local.test.ts +0 -88
  162. package/src/commands/run.test.ts +0 -45
  163. package/src/commands/run.ts +0 -184
  164. package/src/commands/safe-extract.test.ts +0 -459
  165. package/src/commands/safe-extract.ts +0 -444
  166. package/src/commands/secrets-scope.test.ts +0 -56
  167. package/src/commands/secrets-scope.ts +0 -32
  168. package/src/commands/secrets.parse-destination.test.ts +0 -38
  169. package/src/commands/secrets.test.ts +0 -2037
  170. package/src/commands/secrets.ts +0 -2240
  171. package/src/commands/signals.ts +0 -351
  172. package/src/commands/skill.test.ts +0 -770
  173. package/src/commands/skill.ts +0 -796
  174. package/src/commands/sources.ts +0 -362
  175. package/src/commands/sync-mode.test.ts +0 -366
  176. package/src/commands/sync-mode.ts +0 -387
  177. package/src/commands/sync-narrow.test.ts +0 -633
  178. package/src/commands/sync-narrow.ts +0 -555
  179. package/src/commands/sync.ts +0 -165
  180. package/src/commands/team-sync.ts +0 -628
  181. package/src/commands/update.ts +0 -71
  182. package/src/commands/whoami.ts +0 -83
  183. package/src/commands/workers.test.ts +0 -158
  184. package/src/commands/workers.ts +0 -301
  185. package/src/index.test.ts +0 -32
  186. package/src/index.ts +0 -20
  187. package/src/lib/db/control-plane.test.ts +0 -59
  188. package/src/lib/db/control-plane.ts +0 -113
  189. package/src/lib/db/local.test.ts +0 -81
  190. package/src/lib/db/local.ts +0 -148
  191. package/src/lib/db/migrate.test.ts +0 -133
  192. package/src/lib/db/migrate.ts +0 -137
  193. package/src/lib/db/paths.test.ts +0 -112
  194. package/src/lib/db/paths.ts +0 -128
  195. package/src/lib/db/remote-engine.test.ts +0 -44
  196. package/src/lib/db/remote-engine.ts +0 -148
  197. package/src/lib/db/remote-sql.test.ts +0 -32
  198. package/src/lib/db/remote-sql.ts +0 -62
  199. package/src/lib/db/sql.test.ts +0 -106
  200. package/src/lib/db/sql.ts +0 -192
  201. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  202. package/src/lib/local-tree-diff.test.ts +0 -301
  203. package/src/lib/local-tree-diff.ts +0 -362
  204. package/src/lib/narrow-hint-banner.test.ts +0 -235
  205. package/src/lib/narrow-hint-banner.ts +0 -212
  206. package/src/lib/plan-limit-nag.test.ts +0 -317
  207. package/src/lib/plan-limit-nag.ts +0 -264
  208. package/src/main.ts +0 -371
  209. package/src/node-preflight.test.ts +0 -60
  210. package/src/node-preflight.ts +0 -67
  211. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  212. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  213. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  214. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  215. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  216. package/src/outpost/session-heartbeat.test.ts +0 -459
  217. package/src/outpost/session-heartbeat.ts +0 -877
  218. package/src/packaging.test.ts +0 -109
  219. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  220. package/src/run/discover-schemas.test.ts +0 -153
  221. package/src/run/discover-schemas.ts +0 -79
  222. package/src/run/hq-plugin.test.ts +0 -371
  223. package/src/run/hq-plugin.ts +0 -277
  224. package/src/run/varlock-shape.test.ts +0 -57
  225. package/src/schemas/hq-package.schema.json +0 -155
  226. package/src/sentry-before-send.test.ts +0 -359
  227. package/src/sentry-before-send.ts +0 -221
  228. package/src/sentry-epipe.test.ts +0 -37
  229. package/src/sentry-release.test.ts +0 -54
  230. package/src/sentry.test.ts +0 -24
  231. package/src/sentry.ts +0 -54
  232. package/src/strategies/link.ts +0 -62
  233. package/src/strategies/merge.ts +0 -142
  234. package/src/types.ts +0 -146
  235. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  236. package/src/utils/auth-error.test.ts +0 -40
  237. package/src/utils/auth-error.ts +0 -42
  238. package/src/utils/billing-gate.test.ts +0 -95
  239. package/src/utils/billing-gate.ts +0 -228
  240. package/src/utils/breadcrumb-buffer.ts +0 -18
  241. package/src/utils/cli-telemetry.test.ts +0 -153
  242. package/src/utils/cli-telemetry.ts +0 -61
  243. package/src/utils/cognito-session.machine.test.ts +0 -109
  244. package/src/utils/cognito-session.refresh.test.ts +0 -84
  245. package/src/utils/cognito-session.test.ts +0 -328
  246. package/src/utils/cognito-session.ts +0 -448
  247. package/src/utils/company-selection-error.test.ts +0 -42
  248. package/src/utils/company-selection-error.ts +0 -45
  249. package/src/utils/contribution-table.ts +0 -83
  250. package/src/utils/environmental-error.test.ts +0 -45
  251. package/src/utils/environmental-error.ts +0 -39
  252. package/src/utils/epipe.test.ts +0 -28
  253. package/src/utils/epipe.ts +0 -29
  254. package/src/utils/expected-cli-error.test.ts +0 -28
  255. package/src/utils/expected-cli-error.ts +0 -39
  256. package/src/utils/feedback-diagnostics.test.ts +0 -183
  257. package/src/utils/feedback-diagnostics.ts +0 -123
  258. package/src/utils/feedback-screenshots.test.ts +0 -134
  259. package/src/utils/feedback-screenshots.ts +0 -124
  260. package/src/utils/feedback-versions.test.ts +0 -98
  261. package/src/utils/feedback-versions.ts +0 -68
  262. package/src/utils/git.ts +0 -74
  263. package/src/utils/integrity.ts +0 -59
  264. package/src/utils/intercepted-process-exit.test.ts +0 -37
  265. package/src/utils/intercepted-process-exit.ts +0 -36
  266. package/src/utils/login-provider.test.ts +0 -42
  267. package/src/utils/login-provider.ts +0 -30
  268. package/src/utils/manifest.test.ts +0 -167
  269. package/src/utils/manifest.ts +0 -131
  270. package/src/utils/pack-contributions.test.ts +0 -603
  271. package/src/utils/pack-contributions.ts +0 -450
  272. package/src/utils/people.ts +0 -215
  273. package/src/utils/registry-client.ts +0 -204
  274. package/src/utils/registry.ts +0 -90
  275. package/src/utils/resolve-vault-credential.test.ts +0 -69
  276. package/src/utils/resolve-vault-credential.ts +0 -60
  277. package/src/utils/sandbox-runner-client.test.ts +0 -279
  278. package/src/utils/sandbox-runner-client.ts +0 -260
  279. package/src/utils/secrets-cache.ts +0 -183
  280. package/src/utils/sentry-identity.ts +0 -45
  281. package/src/utils/settle-with-timeout.test.ts +0 -21
  282. package/src/utils/settle-with-timeout.ts +0 -22
  283. package/src/utils/user-yaml-error.test.ts +0 -24
  284. package/src/utils/user-yaml-error.ts +0 -30
  285. package/src/utils/vault-api.test.ts +0 -536
  286. package/src/utils/vault-api.ts +0 -382
  287. package/src/utils/version-check.test.ts +0 -176
  288. package/src/utils/version-check.ts +0 -155
  289. package/src/utils/version-gate.test.ts +0 -1024
  290. package/src/utils/version-gate.ts +0 -702
  291. package/test/commands/db-tenant-isolation.test.ts +0 -94
  292. package/test/commands/db.test.ts +0 -85
  293. package/test/commands/signals.test.ts +0 -207
  294. package/test/commands/sources.test.ts +0 -231
  295. package/test/e2e/smoke-install-mcp.sh +0 -113
  296. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  297. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  298. package/test/fixtures/signals/action_item/sample.md +0 -16
  299. package/test/fixtures/signals/summary/sample.md +0 -12
  300. package/test/fixtures/sources/meetings/sample.md +0 -25
  301. package/test/helpers/cli-runner.ts +0 -150
  302. package/test/helpers/s3-list-mock.ts +0 -79
  303. package/test/helpers/vault-service-mock.ts +0 -295
  304. package/test/sources-signals/smoke.test.ts +0 -228
  305. package/tsconfig.json +0 -20
  306. package/vitest.config.ts +0 -11
  307. package/vitest.e2e.config.ts +0 -7
@@ -1,1506 +0,0 @@
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 through the multi-tenant STS routes —
21
- * `VaultClient.sts.vend` (`/sts/vend`, company) and `.sts.vendSelf`
22
- * (`/sts/vend-self`, personal). These resolve the caller's per-entity bucket
23
- * and apply role/ACL scoping server-side (owner/admin → full access; member/
24
- * guest → per-prefix). The legacy `POST /vend` is deliberately NOT used: it
25
- * assumes a single static `BUCKET_ARN` that is unset in multi-tenant prod, so
26
- * it builds an invalid policy and STS rejects it (`MalformedPolicyDocument`).
27
- *
28
- * Namespace note: company vault keys are company-relative (no
29
- * `companies/<slug>/` prefix). The CLI speaks the anchored form for user
30
- * familiarity and translates at the S3 boundary via `toBucketRelative` /
31
- * `toCompanyAnchored`.
32
- *
33
- * Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
34
- * methods and `grantPathToPrefix` from hq-cloud.
35
- */
36
-
37
- import { Command } from "commander";
38
- import chalk from "chalk";
39
- import * as fs from "node:fs";
40
- import * as path from "node:path";
41
- import { Readable } from "node:stream";
42
- import { pipeline } from "node:stream/promises";
43
-
44
- import {
45
- S3Client,
46
- ListObjectsV2Command,
47
- GetObjectCommand,
48
- type ListObjectsV2CommandOutput,
49
- type GetObjectCommandOutput,
50
- } from "@aws-sdk/client-s3";
51
-
52
- import {
53
- VaultClient,
54
- grantPathToPrefix,
55
- type ExplicitGrant,
56
- } from "@indigoai-us/hq-cloud";
57
-
58
- import {
59
- DEFAULT_HQ_ROOT,
60
- DEFAULT_COGNITO,
61
- ensureCognitoToken,
62
- buildVaultConfig,
63
- } from "../utils/cognito-session.js";
64
- import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
65
- import { resolveCanonicalPersonUid } from "./cloud.js";
66
-
67
- // ── Types ───────────────────────────────────────────────────────────────────
68
-
69
- /** STS-vended credential set the browse/cat path consumes. */
70
- export interface BrowseCredentials {
71
- accessKeyId: string;
72
- secretAccessKey: string;
73
- sessionToken: string;
74
- }
75
-
76
- /** Minimal STS-vend response shape (both `/sts/vend` and `/sts/vend-self`). */
77
- export interface BrowseVendResult {
78
- credentials: BrowseCredentials;
79
- }
80
-
81
- /**
82
- * Subset of `VaultClient` this command actually uses — exposed so tests
83
- * can stub vend + grants without standing up a real `VaultClient`.
84
- *
85
- * Browse/cat vend through the multi-tenant `/sts/vend` (company) and
86
- * `/sts/vend-self` (personal) routes — NOT the legacy `POST /vend`, which
87
- * assumes a single static bucket and is non-functional in multi-tenant
88
- * production (it builds a policy against an undefined `BUCKET_ARN`, so STS
89
- * rejects it with `MalformedPolicyDocument`). The STS routes resolve the
90
- * caller's per-entity bucket and apply role/ACL scoping server-side.
91
- */
92
- export interface FilesBrowseVaultClient {
93
- sts: {
94
- vend(input: {
95
- companyUid: string;
96
- durationSeconds?: number;
97
- }): Promise<BrowseVendResult>;
98
- vendSelf(input: {
99
- personUid: string;
100
- durationSeconds?: number;
101
- }): Promise<BrowseVendResult>;
102
- };
103
- listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
104
- entity: {
105
- get(uid: string): Promise<{ uid: string; slug: string; name?: string; bucketName?: string }>;
106
- findInMyNamespace(
107
- type: string,
108
- slug: string,
109
- ): Promise<{ uid: string; slug: string; name?: string; bucketName?: string } | null>;
110
- };
111
- }
112
-
113
- /** Subset of `S3Client` this command actually uses — for test stubs. */
114
- export interface FilesBrowseS3Client {
115
- send(
116
- cmd: ListObjectsV2Command,
117
- ): Promise<ListObjectsV2CommandOutput>;
118
- send(cmd: GetObjectCommand): Promise<GetObjectCommandOutput>;
119
- }
120
-
121
- /** Factory for an S3 client given vended credentials. Injectable for tests. */
122
- export type S3ClientFactory = (input: {
123
- region: string;
124
- credentials: {
125
- accessKeyId: string;
126
- secretAccessKey: string;
127
- sessionToken: string;
128
- };
129
- }) => FilesBrowseS3Client;
130
-
131
- /**
132
- * Factory for the COMPANY-mode browse client. Injectable for tests; the
133
- * production implementation is `createCompanyPresignClient`. Keyed by
134
- * `companyUid` (resolved by the orchestrator) — the access token is captured
135
- * by the closure at the CLI layer.
136
- */
137
- export type CompanyBrowseClientFactory = (input: {
138
- companyUid: string;
139
- }) => FilesBrowseS3Client;
140
-
141
- /**
142
- * ACL provenance for a single listed key.
143
- * - `shared-with-you`: an explicit grant the caller holds covers the key.
144
- * - `role-bypass`: the caller has no covering explicit grant, but
145
- * owner/admin role widened the browse-vend policy to include it.
146
- * - `personal-vault`: the key lives in the caller's own person-entity
147
- * vault, where no grants graph applies — the caller is the only
148
- * principal with access by construction. Emitted only when
149
- * `runBrowse({ personalMode: true })`.
150
- */
151
- export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
152
-
153
- export interface BrowseRow {
154
- key: string;
155
- size: number;
156
- lastModified: Date | undefined;
157
- aclSource: AclSource;
158
- }
159
-
160
- // ── Pure helpers ────────────────────────────────────────────────────────────
161
-
162
- /**
163
- * Parse the company slug from a vault prefix. Vault paths are anchored at
164
- * `companies/<slug>/...`; anything else is rejected so we never try to
165
- * browse a non-company tree (e.g. `personal/`) with a company-vend.
166
- */
167
- export function parseCompanySlugFromPath(prefix: string): string {
168
- const normalized = prefix.replace(/^\/+/, "");
169
- const parts = normalized.split("/");
170
- if (parts.length < 2 || parts[0] !== "companies" || !parts[1]) {
171
- throw new Error(
172
- `Invalid browse path '${prefix}'. Expected a path starting with 'companies/<slug>/'.`,
173
- );
174
- }
175
- return parts[1];
176
- }
177
-
178
- /**
179
- * Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
180
- * the vault bucket actually stores. Company vault buckets are already
181
- * company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
182
- * `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
183
- * the anchored form for user familiarity; we strip the anchor at the S3
184
- * boundary. A path without the anchor (or personal-mode, bucket-relative
185
- * paths) passes through unchanged.
186
- */
187
- export function toBucketRelative(pathOrPrefix: string, slug: string): string {
188
- const anchor = `companies/${slug}/`;
189
- const normalized = pathOrPrefix.replace(/^\/+/, "");
190
- return normalized.startsWith(anchor)
191
- ? normalized.slice(anchor.length)
192
- : normalized;
193
- }
194
-
195
- /**
196
- * Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
197
- * for display + `hq files cat` round-trip, so the CLI surface keeps speaking
198
- * the anchored form the user passed in.
199
- */
200
- export function toCompanyAnchored(bucketRelKey: string, slug: string): string {
201
- return `companies/${slug}/${bucketRelKey}`;
202
- }
203
-
204
- /**
205
- * Classify a single company-relative S3 key against the caller's
206
- * (already-normalized) explicit-grant prefixes. Any prefix that covers the
207
- * key contributes `shared-with-you`; otherwise the key is visible only via
208
- * the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
209
- * prefix is a company-wide grant and matches everything.
210
- *
211
- * Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
212
- * to company-relative `startsWith` prefixes via `grantPathToPrefix` before
213
- * calling this — keeping this helper pure and trivially testable.
214
- */
215
- export function classifyAclSource(
216
- bucketRelKey: string,
217
- grantPrefixes: string[],
218
- ): AclSource {
219
- for (const p of grantPrefixes) {
220
- if (p === "" || bucketRelKey.startsWith(p)) return "shared-with-you";
221
- }
222
- return "role-bypass";
223
- }
224
-
225
- /**
226
- * Bright-line guard for `--out`: refuse to write any byte beneath
227
- * `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
228
- * slug-by-slug — `companies/` is the entire surface hq-sync owns, so a
229
- * containment check on that parent suffices and avoids drift with the
230
- * manifest file. Returns the resolved absolute output path on success;
231
- * throws when the destination would land inside the protected tree.
232
- */
233
- export function assertOutPathOutsideCompanies(
234
- outPath: string,
235
- hqRoot: string,
236
- ): string {
237
- const absOut = path.resolve(outPath);
238
- const protectedRoot = path.resolve(hqRoot, "companies") + path.sep;
239
- if (absOut === path.resolve(hqRoot, "companies") || absOut.startsWith(protectedRoot)) {
240
- throw new Error(
241
- `Refusing to write '${absOut}': bytes peeked via 'hq files cat' must not land under ` +
242
- `'${path.resolve(hqRoot, "companies")}'. Pick an --out path outside the HQ companies tree.`,
243
- );
244
- }
245
- return absOut;
246
- }
247
-
248
- /**
249
- * Render a browse listing as a padded table. Mirrors the chalk + padEnd
250
- * pattern used by `hq sync mode --show` so the CLI surface stays
251
- * stylistically consistent.
252
- */
253
- export function formatBrowseTable(rows: BrowseRow[]): string {
254
- if (rows.length === 0) {
255
- return "No objects under that prefix.";
256
- }
257
- const cols = ["KEY", "SIZE", "MODIFIED", "ACL"];
258
- const data = rows.map((r) => [
259
- r.key,
260
- String(r.size),
261
- r.lastModified ? r.lastModified.toISOString() : "—",
262
- r.aclSource,
263
- ]);
264
- const widths = cols.map((c, i) =>
265
- Math.max(c.length, ...data.map((row) => row[i].length)),
266
- );
267
- const renderRow = (row: string[]): string =>
268
- row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
269
- const lines = [
270
- chalk.bold(renderRow(cols)),
271
- chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
272
- ...data.map(renderRow),
273
- ];
274
- return lines.join("\n");
275
- }
276
-
277
- // ── Orchestrators ───────────────────────────────────────────────────────────
278
-
279
- export interface RunBrowseInput {
280
- /**
281
- * Vault path prefix.
282
- * - Company mode (`personalMode: false | undefined`): must start with
283
- * `companies/<slug>/`, e.g. `companies/indigo/scratch/`.
284
- * - Personal mode (`personalMode: true`): bucket-relative; empty string
285
- * lists the whole personal vault root.
286
- */
287
- pathPrefix: string;
288
- /** Caller-overridden company slug (defaults to slug parsed from path). Ignored under `personalMode`. */
289
- companySlug?: string;
290
- /**
291
- * Personal-vault mode. Skips the `companies/<slug>/` path requirement,
292
- * resolves the entity via `entity.get(personalUid)` instead of the
293
- * company namespace, omits the explicit-grants fetch (no grants graph
294
- * on a person bucket), and marks every row's `aclSource` as
295
- * `"personal-vault"`. Closes hq-cli#26 (audit gap for personal vault).
296
- */
297
- personalMode?: boolean;
298
- /**
299
- * Canonical person-entity UID (e.g. `prs_…`). Required when
300
- * `personalMode: true`; ignored otherwise. Caller resolves via
301
- * `resolveCanonicalPersonUid` to keep this orchestrator pure.
302
- */
303
- personalUid?: string;
304
- vaultClient: FilesBrowseVaultClient;
305
- /** PERSONAL mode: builds a direct-S3 client from vended creds. */
306
- s3Factory?: S3ClientFactory;
307
- /**
308
- * COMPANY mode (HQ-59): builds the presign/list-backed client. Company
309
- * browse no longer talks to S3 directly and does not vend STS creds.
310
- */
311
- companyClient?: CompanyBrowseClientFactory;
312
- region: string;
313
- }
314
-
315
- export interface RunBrowseResult {
316
- rows: BrowseRow[];
317
- /**
318
- * Present ONLY for the PERSONAL (vendSelf + direct S3) path. Company mode
319
- * goes through the presign/list API and does not vend, so this is undefined
320
- * there.
321
- */
322
- vend?: BrowseVendResult;
323
- }
324
-
325
- /** Guard: the personal path needs an S3 factory. */
326
- function requirePersonalS3Factory(f?: S3ClientFactory): S3ClientFactory {
327
- if (!f) {
328
- throw new Error(
329
- "Personal-vault browse requires an s3Factory (direct-S3 client).",
330
- );
331
- }
332
- return f;
333
- }
334
-
335
- /** Guard: the company path needs the presign/list client factory. */
336
- function requireCompanyClient(
337
- f?: CompanyBrowseClientFactory,
338
- ): CompanyBrowseClientFactory {
339
- if (!f) {
340
- throw new Error(
341
- "Company browse requires a companyClient (presign/list client).",
342
- );
343
- }
344
- return f;
345
- }
346
-
347
- /**
348
- * `hq files browse <path>` orchestrator.
349
- *
350
- * 1. Parse slug from prefix (or use override).
351
- * 2. Resolve companyUid + bucketName via VaultClient.entity.
352
- * 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
353
- * `/sts/vend-self` personal) — resolves the per-entity bucket + role/ACL.
354
- * 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
355
- * company-relative key space.
356
- * 5. Fetch explicit grants once, classify each key.
357
- *
358
- * Pure-ish: no console output, no process.exit — caller renders + exits.
359
- */
360
- export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult> {
361
- const { pathPrefix, vaultClient, region, personalMode } = input;
362
-
363
- // Branch by mode. Company mode parses slug from path and looks up by
364
- // namespace; personal mode resolves the entity directly by the supplied
365
- // person UID and skips the slug + grants machinery (a person bucket has
366
- // no grants graph — the owner is the only principal).
367
- let bucket: string;
368
- let entityUid: string;
369
- let slug: string | undefined;
370
- let vend: BrowseVendResult | undefined;
371
- let s3: FilesBrowseS3Client;
372
- if (personalMode) {
373
- if (!input.personalUid) {
374
- throw new Error(
375
- "runBrowse: personalMode requires personalUid. Resolve via " +
376
- "resolveCanonicalPersonUid() before calling.",
377
- );
378
- }
379
- const entity = await vaultClient.entity.get(input.personalUid);
380
- if (!entity.bucketName) {
381
- throw new Error(
382
- `Personal entity '${input.personalUid}' has no provisioned bucket.`,
383
- );
384
- }
385
- entityUid = entity.uid;
386
- bucket = entity.bucketName;
387
- // Personal vault keeps the direct-S3 path: vend self creds, build an S3
388
- // client. (HQ-59 scopes the migration to COMPANY mode.)
389
- vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
390
- s3 = requirePersonalS3Factory(input.s3Factory)({
391
- region,
392
- credentials: {
393
- accessKeyId: vend.credentials.accessKeyId,
394
- secretAccessKey: vend.credentials.secretAccessKey,
395
- sessionToken: vend.credentials.sessionToken,
396
- },
397
- });
398
- } else {
399
- slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
400
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
401
- if (!entity) {
402
- throw new Error(
403
- `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
404
- );
405
- }
406
- if (!entity.bucketName) {
407
- throw new Error(
408
- `Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
409
- );
410
- }
411
- entityUid = entity.uid;
412
- bucket = entity.bucketName;
413
- // COMPANY mode (HQ-59): list/get go through the presign/list API, which
414
- // enforces the same per-file ACLs server-side. No STS vend, no direct S3.
415
- s3 = requireCompanyClient(input.companyClient)({ companyUid: entityUid });
416
- }
417
-
418
- // Company vault keys are company-relative (no `companies/<slug>/` prefix), so
419
- // translate the CLI's anchored prefix into the bucket-relative form before
420
- // listing. Personal-mode paths are already bucket-relative.
421
- const listPrefix =
422
- personalMode || slug === undefined
423
- ? pathPrefix
424
- : toBucketRelative(pathPrefix, slug);
425
-
426
- // Pull the caller's explicit-grant graph once so per-key classification
427
- // is O(grants) without N round-trips. Grant `path`s are normalized to
428
- // company-relative `startsWith` prefixes (matching the bucket key space)
429
- // via `grantPathToPrefix`. Skipped in personal mode — the grants graph is a
430
- // company concept; a person bucket marks every row `"personal-vault"`.
431
- const grantPrefixes =
432
- personalMode || slug === undefined
433
- ? []
434
- : (await vaultClient.listMyExplicitGrants(entityUid)).map((g) =>
435
- grantPathToPrefix(g.path, slug as string),
436
- );
437
-
438
- const rows: BrowseRow[] = [];
439
- let continuationToken: string | undefined;
440
- do {
441
- const resp = (await s3.send(
442
- new ListObjectsV2Command({
443
- Bucket: bucket,
444
- Prefix: listPrefix,
445
- ContinuationToken: continuationToken,
446
- }),
447
- )) as ListObjectsV2CommandOutput;
448
-
449
- for (const obj of resp.Contents ?? []) {
450
- if (!obj.Key) continue;
451
- // Skip S3 "directory marker" objects (0-byte, trailing slash).
452
- if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
453
-
454
- // `obj.Key` is company-relative. Classify in that space, then re-anchor
455
- // for display so the CLI keeps speaking `companies/<slug>/...`.
456
- rows.push({
457
- key:
458
- personalMode || slug === undefined
459
- ? obj.Key
460
- : toCompanyAnchored(obj.Key, slug),
461
- size: obj.Size ?? 0,
462
- lastModified: obj.LastModified,
463
- aclSource: personalMode
464
- ? "personal-vault"
465
- : classifyAclSource(obj.Key, grantPrefixes),
466
- });
467
- }
468
-
469
- continuationToken = resp.NextContinuationToken ?? undefined;
470
- } while (continuationToken);
471
-
472
- return { rows, vend };
473
- }
474
-
475
- export interface RunCatInput {
476
- /**
477
- * Single vault key.
478
- * - Company mode: must be a `companies/<slug>/...` path.
479
- * - Personal mode: bucket-relative, e.g. `.claude/CLAUDE.md`.
480
- */
481
- key: string;
482
- /**
483
- * Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
484
- * against `<hqRoot>/companies/` by `assertOutPathOutsideCompanies`.
485
- */
486
- out?: string;
487
- hqRoot: string;
488
- companySlug?: string;
489
- /** Personal-vault mode — see `RunBrowseInput.personalMode`. */
490
- personalMode?: boolean;
491
- /** Canonical person-entity UID; required when `personalMode: true`. */
492
- personalUid?: string;
493
- vaultClient: FilesBrowseVaultClient;
494
- /** PERSONAL mode: builds a direct-S3 client from vended creds. */
495
- s3Factory?: S3ClientFactory;
496
- /** COMPANY mode (HQ-59): builds the presign/list-backed client. */
497
- companyClient?: CompanyBrowseClientFactory;
498
- region: string;
499
- /** Destination stream for the stdout path. Injectable for tests. */
500
- stdout?: NodeJS.WritableStream;
501
- }
502
-
503
- export interface RunCatResult {
504
- bytesWritten: number;
505
- destination: { kind: "stdout" } | { kind: "file"; absPath: string };
506
- /** Present ONLY for the PERSONAL (vendSelf + direct S3) path. */
507
- vend?: BrowseVendResult;
508
- }
509
-
510
- /**
511
- * `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
512
- * streams the object body either to stdout or to `--out` (after the
513
- * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
514
- */
515
- export async function runCat(input: RunCatInput): Promise<RunCatResult> {
516
- const { key, vaultClient, region, hqRoot, personalMode } = input;
517
-
518
- // Acceptance 5: refuse BEFORE vending — no point pulling credentials
519
- // for a request we're already going to abort.
520
- let absOut: string | undefined;
521
- if (input.out !== undefined) {
522
- absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
523
- }
524
-
525
- // Same branch logic as runBrowse — see that function's doc-block for
526
- // the personal-vs-company rationale.
527
- let bucket: string;
528
- let s3Key: string;
529
- let vend: BrowseVendResult | undefined;
530
- let s3: FilesBrowseS3Client;
531
- if (personalMode) {
532
- if (!input.personalUid) {
533
- throw new Error(
534
- "runCat: personalMode requires personalUid. Resolve via " +
535
- "resolveCanonicalPersonUid() before calling.",
536
- );
537
- }
538
- const entity = await vaultClient.entity.get(input.personalUid);
539
- if (!entity.bucketName) {
540
- throw new Error(
541
- `Personal entity '${input.personalUid}' has no provisioned bucket.`,
542
- );
543
- }
544
- bucket = entity.bucketName;
545
- s3Key = key; // personal-mode keys are already bucket-relative
546
- vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
547
- s3 = requirePersonalS3Factory(input.s3Factory)({
548
- region,
549
- credentials: {
550
- accessKeyId: vend.credentials.accessKeyId,
551
- secretAccessKey: vend.credentials.secretAccessKey,
552
- sessionToken: vend.credentials.sessionToken,
553
- },
554
- });
555
- } else {
556
- const slug = input.companySlug ?? parseCompanySlugFromPath(key);
557
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
558
- if (!entity) {
559
- throw new Error(
560
- `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
561
- );
562
- }
563
- if (!entity.bucketName) {
564
- throw new Error(
565
- `Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
566
- );
567
- }
568
- bucket = entity.bucketName;
569
- // Translate the anchored CLI key into the company-relative bucket key.
570
- s3Key = toBucketRelative(key, slug);
571
- // COMPANY mode (HQ-59): GetObject → presign GET. No STS vend, no direct S3.
572
- s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
573
- }
574
-
575
- const resp = (await s3.send(
576
- new GetObjectCommand({ Bucket: bucket, Key: s3Key }),
577
- )) as GetObjectCommandOutput;
578
-
579
- if (!resp.Body) {
580
- throw new Error(`GetObject for '${key}' returned no body.`);
581
- }
582
-
583
- // The SDK Body type in node is a Readable (it can also be a
584
- // ReadableStream/Blob in other runtimes but those don't apply to the
585
- // CLI). Cast through unknown so the type checker accepts the narrowing.
586
- const body = resp.Body as unknown as Readable;
587
- let bytesWritten = 0;
588
- body.on("data", (chunk: Buffer | string) => {
589
- bytesWritten += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
590
- });
591
-
592
- if (absOut !== undefined) {
593
- // Ensure the parent directory exists — but ONLY if it's also outside
594
- // the protected tree (the guard already validated absOut itself; the
595
- // parent of an outside-tree path is by definition outside too).
596
- fs.mkdirSync(path.dirname(absOut), { recursive: true });
597
- await pipeline(body, fs.createWriteStream(absOut));
598
- return {
599
- bytesWritten,
600
- destination: { kind: "file", absPath: absOut },
601
- vend,
602
- };
603
- }
604
-
605
- await pipeline(body, input.stdout ?? process.stdout);
606
- return { bytesWritten, destination: { kind: "stdout" }, vend };
607
- }
608
-
609
- // ── shared-with-me ────────────────────────────────────────────────────────
610
-
611
- /**
612
- * Subset of `VaultClient` the `shared-with-me` orchestrator uses. No vend / S3
613
- * — this is a pure read of the caller's explicit-grant graph, so it never
614
- * touches the credential/browse vend surface.
615
- */
616
- export interface FilesSharedWithMeVaultClient {
617
- listMyMemberships(): Promise<Array<{ companyUid: string }>>;
618
- listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
619
- entity: {
620
- get(uid: string): Promise<{ uid: string; slug: string; name?: string }>;
621
- };
622
- }
623
-
624
- export interface SharedWithMeRow {
625
- companySlug: string;
626
- /** Company-relative grant path (e.g. `knowledge/`, `reports/q3.pdf`). */
627
- path: string;
628
- permission: ExplicitGrant["permission"];
629
- source: ExplicitGrant["source"];
630
- }
631
-
632
- export interface RunSharedWithMeInput {
633
- vaultClient: FilesSharedWithMeVaultClient;
634
- /**
635
- * Scope to a single company by UID. When omitted, rolls up across every
636
- * company the caller has a membership in (the cross-company "what's shared
637
- * with me everywhere" view).
638
- */
639
- companyUid?: string;
640
- /** Display slug for the single-company case (avoids an extra entity.get). */
641
- companySlug?: string;
642
- }
643
-
644
- /**
645
- * `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
646
- * file-ACL grants — the canonical "what's been shared with me" surface.
647
- * Role-bypass access (owner/admin) is intentionally excluded server-side by
648
- * `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
649
- *
650
- * Pure data — no console output, no S3, no vend. The caller renders + exits.
651
- */
652
- export async function runSharedWithMe(
653
- input: RunSharedWithMeInput,
654
- ): Promise<SharedWithMeRow[]> {
655
- const { vaultClient } = input;
656
-
657
- // Resolve the (companyUid, slug) pairs to query. Single-company when a UID
658
- // was supplied; otherwise fan out across every membership.
659
- let targets: Array<{ uid: string; slug: string }>;
660
- if (input.companyUid) {
661
- targets = [{ uid: input.companyUid, slug: input.companySlug ?? input.companyUid }];
662
- } else {
663
- const memberships = await vaultClient.listMyMemberships();
664
- targets = await Promise.all(
665
- memberships.map(async (m) => {
666
- try {
667
- const ent = await vaultClient.entity.get(m.companyUid);
668
- return { uid: m.companyUid, slug: ent.slug || m.companyUid };
669
- } catch {
670
- // Entity not visible — fall back to the UID as the display label
671
- // rather than dropping the company's grants entirely.
672
- return { uid: m.companyUid, slug: m.companyUid };
673
- }
674
- }),
675
- );
676
- }
677
-
678
- const rows: SharedWithMeRow[] = [];
679
- for (const t of targets) {
680
- let grants: ExplicitGrant[];
681
- try {
682
- grants = await vaultClient.listMyExplicitGrants(t.uid);
683
- } catch {
684
- // A single company's grant fetch failing shouldn't sink the whole
685
- // roll-up — skip it and continue (best-effort discovery view).
686
- continue;
687
- }
688
- for (const g of grants) {
689
- rows.push({
690
- companySlug: t.slug,
691
- path: g.path,
692
- permission: g.permission,
693
- source: g.source,
694
- });
695
- }
696
- }
697
-
698
- // Stable sort: company, then path — deterministic output for humans + tests.
699
- rows.sort((a, b) =>
700
- a.companySlug === b.companySlug
701
- ? a.path.localeCompare(b.path)
702
- : a.companySlug.localeCompare(b.companySlug),
703
- );
704
- return rows;
705
- }
706
-
707
- /**
708
- * Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
709
- */
710
- export function formatSharedWithMeTable(rows: SharedWithMeRow[]): string {
711
- if (rows.length === 0) {
712
- return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
713
- }
714
- const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
715
- const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
716
- const widths = cols.map((c, i) =>
717
- Math.max(c.length, ...data.map((row) => row[i].length)),
718
- );
719
- const renderRow = (row: string[]): string =>
720
- row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
721
- return [
722
- chalk.bold(renderRow(cols)),
723
- chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
724
- ...data.map(renderRow),
725
- ].join("\n");
726
- }
727
-
728
- // ── search ───────────────────────────────────────────────────────────────
729
-
730
- export interface RunSearchInput {
731
- /** Case-insensitive substring matched against each object's full key. */
732
- query: string;
733
- /** Company slug to search (ignored under personalMode). */
734
- companySlug: string;
735
- personalMode?: boolean;
736
- personalUid?: string;
737
- vaultClient: FilesBrowseVaultClient;
738
- /** PERSONAL mode: direct-S3 client factory. */
739
- s3Factory?: S3ClientFactory;
740
- /** COMPANY mode (HQ-59): presign/list client factory. */
741
- companyClient?: CompanyBrowseClientFactory;
742
- region: string;
743
- }
744
-
745
- /**
746
- * `hq files search <query>` orchestrator. Lists the company (or personal)
747
- * vault under its root via `runBrowse`, then filters to keys containing the
748
- * query (case-insensitive). v1 is a name/path search over the listing — no
749
- * content search. Rows carry the same ACL-source classification as browse.
750
- *
751
- * Pure-ish: no console output. The caller renders with `formatBrowseTable`.
752
- */
753
- export async function runSearch(input: RunSearchInput): Promise<BrowseRow[]> {
754
- const prefix = input.personalMode ? "" : `companies/${input.companySlug}/`;
755
- const { rows } = await runBrowse({
756
- pathPrefix: prefix,
757
- companySlug: input.companySlug,
758
- personalMode: input.personalMode,
759
- personalUid: input.personalUid,
760
- vaultClient: input.vaultClient,
761
- s3Factory: input.s3Factory,
762
- companyClient: input.companyClient,
763
- region: input.region,
764
- });
765
- const q = input.query.toLowerCase();
766
- return rows.filter((r) => r.key.toLowerCase().includes(q));
767
- }
768
-
769
- // ── get (materialize + pin) ─────────────────────────────────────────────────
770
-
771
- interface PinFile {
772
- version: number;
773
- /** companySlug → sorted list of company-relative pinned prefixes. */
774
- pins: Record<string, string[]>;
775
- }
776
-
777
- /** Per-machine pin set path: `<hqRoot>/.hq/pins.json`. */
778
- export function pinFilePath(hqRoot: string): string {
779
- return path.join(hqRoot, ".hq", "pins.json");
780
- }
781
-
782
- /** Read the pin set, tolerating a missing or corrupt file (→ fresh). */
783
- export function readPins(hqRoot: string): PinFile {
784
- try {
785
- const parsed = JSON.parse(fs.readFileSync(pinFilePath(hqRoot), "utf-8"));
786
- if (parsed && typeof parsed === "object" && parsed.pins) {
787
- return { version: parsed.version ?? 1, pins: parsed.pins };
788
- }
789
- } catch {
790
- /* missing / unreadable / malformed → start fresh */
791
- }
792
- return { version: 1, pins: {} };
793
- }
794
-
795
- /**
796
- * Register a company-relative prefix in the per-machine pin set. Pins are what
797
- * keep an on-demand `hq files get` from being pruned by the next *scoped*
798
- * sync (`syncMode: shared|custom`): the sync runner unions the company's pins
799
- * into its pull scope. Idempotent + sorted for stable diffs.
800
- */
801
- export function addPin(hqRoot: string, companySlug: string, prefix: string): void {
802
- const pf = readPins(hqRoot);
803
- const list = pf.pins[companySlug] ?? [];
804
- if (!list.includes(prefix)) list.push(prefix);
805
- list.sort();
806
- pf.pins[companySlug] = list;
807
- const f = pinFilePath(hqRoot);
808
- fs.mkdirSync(path.dirname(f), { recursive: true });
809
- fs.writeFileSync(f, JSON.stringify(pf, null, 2) + "\n");
810
- }
811
-
812
- export interface RunGetInput {
813
- /** Vault path to materialize: `companies/<slug>/...` (file or prefix). */
814
- path: string;
815
- /**
816
- * Override destination directory. Default (omitted) writes in place under
817
- * `<hqRoot>/companies/<slug>/...` — the tree `hq sync` manages — and
818
- * registers a pin. `--into` writes outside that envelope and pins nothing.
819
- */
820
- into?: string;
821
- hqRoot: string;
822
- companySlug?: string;
823
- vaultClient: FilesBrowseVaultClient;
824
- /**
825
- * COMPANY mode (HQ-59): presign/list client factory. `get` is company-only,
826
- * so it always goes through the API — no direct S3, no STS vend.
827
- */
828
- companyClient?: CompanyBrowseClientFactory;
829
- region: string;
830
- }
831
-
832
- export interface RunGetResult {
833
- filesWritten: number;
834
- bytesWritten: number;
835
- destinations: string[];
836
- /** Set only for in-place (no `--into`) materialization. */
837
- pinned?: { companySlug: string; prefix: string };
838
- }
839
-
840
- /**
841
- * `hq files get <path>` orchestrator. Materializes a vault file or prefix into
842
- * the local HQ tree on demand. Unlike `cat` (which refuses to write under
843
- * `companies/`), `get` deliberately writes INTO `companies/<slug>/...` by
844
- * default — that's the point: pull a path you have access to but don't sync.
845
- * It then registers a pin so the next scoped sync keeps it.
846
- *
847
- * Company mode only in v1 — materializing a personal vault would target the
848
- * HQ root itself, which is too broad to do implicitly.
849
- */
850
- export async function runGet(input: RunGetInput): Promise<RunGetResult> {
851
- const { path: vaultPath, vaultClient, hqRoot } = input;
852
- const slug = input.companySlug ?? parseCompanySlugFromPath(vaultPath);
853
-
854
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
855
- if (!entity) {
856
- throw new Error(
857
- `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
858
- );
859
- }
860
- if (!entity.bucketName) {
861
- throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
862
- }
863
- const bucket = entity.bucketName;
864
- // COMPANY mode (HQ-59): list + get through the presign/list API. No STS vend,
865
- // no direct S3 — the server enforces the same per-file read ACLs.
866
- const s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
867
-
868
- // Company-relative prefix to list/fetch (bucket keys carry no anchor).
869
- const bucketPrefix = toBucketRelative(vaultPath, slug);
870
-
871
- const keys: string[] = [];
872
- let continuationToken: string | undefined;
873
- do {
874
- const resp = (await s3.send(
875
- new ListObjectsV2Command({
876
- Bucket: bucket,
877
- Prefix: bucketPrefix,
878
- ContinuationToken: continuationToken,
879
- }),
880
- )) as ListObjectsV2CommandOutput;
881
- for (const obj of resp.Contents ?? []) {
882
- if (!obj.Key) continue;
883
- if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
884
- keys.push(obj.Key);
885
- }
886
- continuationToken = resp.NextContinuationToken ?? undefined;
887
- } while (continuationToken);
888
-
889
- if (keys.length === 0) {
890
- throw new Error(`No objects under '${vaultPath}'.`);
891
- }
892
-
893
- const destinations: string[] = [];
894
- let bytesWritten = 0;
895
- for (const key of keys) {
896
- // Default: in-place under companies/<slug>/<company-relative key>.
897
- // --into: write the path relative to the requested prefix under <into>.
898
- let destAbs: string;
899
- if (input.into !== undefined) {
900
- const rel = key.startsWith(bucketPrefix)
901
- ? key.slice(bucketPrefix.length)
902
- : key;
903
- destAbs = path.resolve(input.into, rel || path.basename(key));
904
- } else {
905
- destAbs = path.join(hqRoot, "companies", slug, key);
906
- }
907
- const resp = (await s3.send(
908
- new GetObjectCommand({ Bucket: bucket, Key: key }),
909
- )) as GetObjectCommandOutput;
910
- if (!resp.Body) {
911
- throw new Error(`GetObject for '${key}' returned no body.`);
912
- }
913
- const body = resp.Body as unknown as Readable;
914
- fs.mkdirSync(path.dirname(destAbs), { recursive: true });
915
- await pipeline(body, fs.createWriteStream(destAbs));
916
- bytesWritten += fs.statSync(destAbs).size;
917
- destinations.push(destAbs);
918
- }
919
-
920
- // Pin only the in-place case — `--into` writes outside the sync envelope, so
921
- // there's nothing for a scoped sync to prune.
922
- let pinned: RunGetResult["pinned"];
923
- if (input.into === undefined) {
924
- addPin(hqRoot, slug, bucketPrefix);
925
- pinned = { companySlug: slug, prefix: bucketPrefix };
926
- }
927
-
928
- return { filesWritten: keys.length, bytesWritten, destinations, pinned };
929
- }
930
-
931
- // ── CLI registration ────────────────────────────────────────────────────────
932
-
933
- const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
934
- new S3Client({ region, credentials });
935
-
936
- // ── Company-mode presign/list client (HQ-59) ────────────────────────────────
937
- //
938
- // The COMPANY browse/cat/get/search path no longer talks to S3 directly. Per
939
- // the HQ-59 directive ("no client talks to S3 directly"), it goes through the
940
- // server-side vault API, which enforces the SAME per-file ACLs the STS vend
941
- // policy used to — so the per-file STS scoping can be dropped later:
942
- // - ListObjectsV2 → GET /v1/files/list (flat, ACL-filtered, paginated)
943
- // - GetObject → POST /v1/files/presign (op:'get') → fetch the URL
944
- //
945
- // It implements the existing `FilesBrowseS3Client` interface so the
946
- // orchestrators' S3-shaped calls are unchanged; the `Bucket` field on each
947
- // command is ignored (the server resolves the bucket from the companyUid).
948
- // The PERSONAL (vendSelf) path keeps using direct S3 via `defaultS3Factory`.
949
-
950
- /** Shape of one object in a GET /v1/files/list response. */
951
- interface FilesListObject {
952
- key: string;
953
- size: number;
954
- lastModified: string | null;
955
- etag: string | null;
956
- permission: string;
957
- }
958
-
959
- /**
960
- * Build a COMPANY-mode browse client backed by the list + presign API. The
961
- * access token + companyUid are captured here; the orchestrator just calls
962
- * `send(...)` as if it held an S3 client.
963
- */
964
- export function createCompanyPresignClient(input: {
965
- token: string;
966
- companyUid: string;
967
- }): FilesBrowseS3Client {
968
- const { token, companyUid } = input;
969
-
970
- async function listObjects(
971
- cmd: ListObjectsV2Command,
972
- ): Promise<ListObjectsV2CommandOutput> {
973
- const prefix = cmd.input.Prefix ?? "";
974
- const query: Record<string, string> = { company: companyUid };
975
- if (prefix.length > 0) query.prefix = prefix;
976
- if (cmd.input.ContinuationToken) query.cursor = cmd.input.ContinuationToken;
977
-
978
- const res = await vaultApiFetch({ token, path: "/v1/files/list", query });
979
- if (!res.ok) {
980
- const body = (await res.json().catch(() => ({}))) as Record<string, string>;
981
- throw new Error(
982
- body.message ?? body.error ?? `files list failed (${res.status})`,
983
- );
984
- }
985
- const body = (await res.json()) as {
986
- objects?: FilesListObject[];
987
- cursor?: string | null;
988
- truncated?: boolean;
989
- };
990
- return {
991
- // Map the API's company-relative objects onto the ListObjectsV2 shape the
992
- // orchestrators read (Key / Size / LastModified / ETag). Re-quote the
993
- // etag to match S3's quoted form, in case any caller compares it.
994
- Contents: (body.objects ?? []).map((o) => ({
995
- Key: o.key,
996
- Size: o.size,
997
- LastModified: o.lastModified ? new Date(o.lastModified) : undefined,
998
- ETag: o.etag != null ? `"${o.etag}"` : undefined,
999
- })),
1000
- NextContinuationToken: body.cursor ?? undefined,
1001
- IsTruncated: Boolean(body.truncated),
1002
- // $metadata is required by the SDK output type; the orchestrators never
1003
- // read it, so a minimal stub is sufficient.
1004
- $metadata: {},
1005
- } as ListObjectsV2CommandOutput;
1006
- }
1007
-
1008
- async function getObject(
1009
- cmd: GetObjectCommand,
1010
- ): Promise<GetObjectCommandOutput> {
1011
- const key = cmd.input.Key as string;
1012
- const res = await vaultApiFetch({
1013
- token,
1014
- path: "/v1/files/presign",
1015
- method: "POST",
1016
- body: { company: companyUid, key, op: "get" },
1017
- });
1018
- if (!res.ok) {
1019
- const body = (await res.json().catch(() => ({}))) as Record<string, string>;
1020
- throw new Error(
1021
- body.message ?? body.error ?? `presign failed (${res.status})`,
1022
- );
1023
- }
1024
- const body = (await res.json()) as {
1025
- results?: Array<{ key: string; url?: string; error?: string; code?: string }>;
1026
- };
1027
- const first = body.results?.[0];
1028
- if (!first || !first.url) {
1029
- // Per-key denial/validation surfaces here (e.g. FILES_PRESIGN_FORBIDDEN).
1030
- throw new Error(first?.error ?? `No presigned URL returned for '${key}'`);
1031
- }
1032
-
1033
- const dl = await fetch(first.url);
1034
- if (!dl.ok) {
1035
- throw new Error(`Failed to download '${key}' (HTTP ${dl.status})`);
1036
- }
1037
- // fetch() yields a web ReadableStream; the orchestrators consume Body as a
1038
- // Node Readable (body.on('data') + stream pipeline), so adapt it. An empty
1039
- // body (no stream) becomes an empty Readable.
1040
- const nodeBody = dl.body
1041
- ? Readable.fromWeb(dl.body as Parameters<typeof Readable.fromWeb>[0])
1042
- : Readable.from([]);
1043
- return { Body: nodeBody, $metadata: {} } as unknown as GetObjectCommandOutput;
1044
- }
1045
-
1046
- function send(cmd: ListObjectsV2Command): Promise<ListObjectsV2CommandOutput>;
1047
- function send(cmd: GetObjectCommand): Promise<GetObjectCommandOutput>;
1048
- function send(
1049
- cmd: ListObjectsV2Command | GetObjectCommand,
1050
- ): Promise<ListObjectsV2CommandOutput | GetObjectCommandOutput> {
1051
- if (cmd instanceof ListObjectsV2Command) return listObjects(cmd);
1052
- if (cmd instanceof GetObjectCommand) return getObject(cmd);
1053
- throw new Error("createCompanyPresignClient: unsupported S3 command");
1054
- }
1055
-
1056
- return { send };
1057
- }
1058
-
1059
- /** Production company-mode client factory — closes over the caller's token. */
1060
- function makeCompanyPresignFactory(token: string): CompanyBrowseClientFactory {
1061
- return ({ companyUid }) => createCompanyPresignClient({ token, companyUid });
1062
- }
1063
-
1064
- interface FilesBrowseCliOptions {
1065
- company?: string;
1066
- hqRoot: string;
1067
- /**
1068
- * Personal-vault mode (hq-cli#26). Skips the `companies/<slug>/`
1069
- * requirement on the path arg, resolves the entity from the caller's
1070
- * canonical person UID, and emits rows tagged `personal-vault`.
1071
- * Mutually exclusive with `--company`.
1072
- */
1073
- personal?: boolean;
1074
- }
1075
-
1076
- interface FilesCatCliOptions extends FilesBrowseCliOptions {
1077
- out?: string;
1078
- }
1079
-
1080
- interface FilesSearchCliOptions {
1081
- company?: string;
1082
- personal?: boolean;
1083
- }
1084
-
1085
- interface FilesGetCliOptions {
1086
- company?: string;
1087
- hqRoot: string;
1088
- into?: string;
1089
- }
1090
-
1091
- /**
1092
- * Wire `hq files browse` + `hq files cat` onto an existing `files`
1093
- * Commander group. `registerFilesCommand` in files.ts builds the group
1094
- * and registers `share`/`unshare`/`acl`; this function appends the two
1095
- * new browse-vs-sync subcommands so they share the `--company` switch.
1096
- */
1097
- export function registerFilesBrowseCommands(filesCmd: Command): void {
1098
- filesCmd
1099
- .command("browse [path]")
1100
- .description(
1101
- "List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.",
1102
- )
1103
- .option(
1104
- "--company <slug>",
1105
- "Company slug (defaults to the slug parsed from <path>)",
1106
- )
1107
- .option(
1108
- "--personal",
1109
- "Browse the caller's canonical personal vault. [path] is treated as " +
1110
- "bucket-relative (omit it to list the vault root). Mutually exclusive " +
1111
- "with --company.",
1112
- )
1113
- .option(
1114
- "--hq-root <path>",
1115
- `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
1116
- DEFAULT_HQ_ROOT,
1117
- )
1118
- .action(async (pathArg: string | undefined, options: FilesBrowseCliOptions, command: Command) => {
1119
- try {
1120
- // `--company` is declared on the parent `files` group, so commander
1121
- // binds it there — read merged opts to see it from the subcommand.
1122
- const company = command.optsWithGlobals().company as string | undefined;
1123
- if (options.personal && company) {
1124
- throw new Error(
1125
- "--personal and --company are mutually exclusive. Pick one.",
1126
- );
1127
- }
1128
-
1129
- const accessToken = await ensureCognitoToken();
1130
- const vaultConfig = buildVaultConfig(accessToken);
1131
- const client = new VaultClient(vaultConfig);
1132
-
1133
- if (options.personal) {
1134
- // Personal-vault path. Resolve the caller's canonical person
1135
- // entity once; the orchestrator does the bucket lookup + vend.
1136
- // Empty [path] → list bucket root.
1137
- const personalUid = await resolveCanonicalPersonUid({
1138
- listMyMemberships: () => client.listMyMemberships(),
1139
- listPersonEntities: () => client.entity.listByType("person"),
1140
- getEntity: async () => null,
1141
- });
1142
-
1143
- const result = await runBrowse({
1144
- pathPrefix: pathArg ?? "",
1145
- personalMode: true,
1146
- personalUid,
1147
- vaultClient: client,
1148
- s3Factory: defaultS3Factory,
1149
- region: DEFAULT_COGNITO.region,
1150
- });
1151
-
1152
- console.log(formatBrowseTable(result.rows));
1153
- return;
1154
- }
1155
-
1156
- // Company path. [path] is required here — the slug parse needs it.
1157
- if (!pathArg) {
1158
- throw new Error(
1159
- "browse: [path] is required when --personal is not set. " +
1160
- "Pass a companies/<slug>/... path, or add --personal to " +
1161
- "browse your personal vault.",
1162
- );
1163
- }
1164
-
1165
- // Resolve slug — CLI flag wins, otherwise parse from path arg.
1166
- const slug = company ?? parseCompanySlugFromPath(pathArg);
1167
-
1168
- // If the user passed `--company` AND the path doesn't begin with
1169
- // companies/<that-slug>/, refuse — we'd otherwise vend creds for
1170
- // one company and list keys from another tree, which never makes
1171
- // sense (defense in depth against operator typos).
1172
- if (company !== undefined) {
1173
- const fromPath = (() => {
1174
- try {
1175
- return parseCompanySlugFromPath(pathArg);
1176
- } catch {
1177
- return undefined;
1178
- }
1179
- })();
1180
- if (fromPath && fromPath !== company) {
1181
- throw new Error(
1182
- `--company '${company}' disagrees with path slug '${fromPath}'.`,
1183
- );
1184
- }
1185
- }
1186
-
1187
- // Confirm the slug resolves to a known membership — same pattern
1188
- // sync-mode/sync-narrow use to surface "you're not a member" early.
1189
- await getCompanyUid(accessToken, slug);
1190
-
1191
- const result = await runBrowse({
1192
- pathPrefix: pathArg,
1193
- companySlug: slug,
1194
- vaultClient: client,
1195
- companyClient: makeCompanyPresignFactory(accessToken),
1196
- region: DEFAULT_COGNITO.region,
1197
- });
1198
-
1199
- console.log(formatBrowseTable(result.rows));
1200
- if (result.rows.length > 0) {
1201
- const bypassCount = result.rows.filter(
1202
- (r) => r.aclSource === "role-bypass",
1203
- ).length;
1204
- if (bypassCount > 0) {
1205
- console.log("");
1206
- console.log(
1207
- chalk.yellow(
1208
- `Heads-up: ${bypassCount} object(s) visible only via role-bypass (no explicit grant covers them).`,
1209
- ),
1210
- );
1211
- }
1212
- }
1213
- } catch (err) {
1214
- console.error(
1215
- chalk.red("Error:"),
1216
- err instanceof Error ? err.message : String(err),
1217
- );
1218
- process.exit(1);
1219
- }
1220
- });
1221
-
1222
- filesCmd
1223
- .command("cat <path>")
1224
- .description(
1225
- "Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path. Pass --personal to read from the caller's personal vault.",
1226
- )
1227
- .option(
1228
- "--out <file>",
1229
- "Write the object body to <file> instead of stdout. Refused under <hqRoot>/companies/.",
1230
- )
1231
- .option(
1232
- "--company <slug>",
1233
- "Company slug (defaults to the slug parsed from <path>)",
1234
- )
1235
- .option(
1236
- "--personal",
1237
- "Read from the caller's canonical personal vault. <path> is treated as " +
1238
- "bucket-relative. Mutually exclusive with --company.",
1239
- )
1240
- .option(
1241
- "--hq-root <path>",
1242
- `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
1243
- DEFAULT_HQ_ROOT,
1244
- )
1245
- .action(async (keyArg: string, options: FilesCatCliOptions, command: Command) => {
1246
- try {
1247
- // `--company` is bound on the parent `files` group — read merged opts.
1248
- const company = command.optsWithGlobals().company as string | undefined;
1249
- if (options.personal && company) {
1250
- throw new Error(
1251
- "--personal and --company are mutually exclusive. Pick one.",
1252
- );
1253
- }
1254
-
1255
- const accessToken = await ensureCognitoToken();
1256
- const vaultConfig = buildVaultConfig(accessToken);
1257
- const client = new VaultClient(vaultConfig);
1258
-
1259
- if (options.personal) {
1260
- const personalUid = await resolveCanonicalPersonUid({
1261
- listMyMemberships: () => client.listMyMemberships(),
1262
- listPersonEntities: () => client.entity.listByType("person"),
1263
- getEntity: async () => null,
1264
- });
1265
-
1266
- const result = await runCat({
1267
- key: keyArg,
1268
- out: options.out,
1269
- hqRoot: options.hqRoot,
1270
- personalMode: true,
1271
- personalUid,
1272
- vaultClient: client,
1273
- s3Factory: defaultS3Factory,
1274
- region: DEFAULT_COGNITO.region,
1275
- });
1276
-
1277
- if (result.destination.kind === "file") {
1278
- console.error(
1279
- chalk.green("✓"),
1280
- `Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`,
1281
- );
1282
- }
1283
- return;
1284
- }
1285
-
1286
- const slug = company ?? parseCompanySlugFromPath(keyArg);
1287
- if (company !== undefined) {
1288
- const fromPath = (() => {
1289
- try {
1290
- return parseCompanySlugFromPath(keyArg);
1291
- } catch {
1292
- return undefined;
1293
- }
1294
- })();
1295
- if (fromPath && fromPath !== company) {
1296
- throw new Error(
1297
- `--company '${company}' disagrees with path slug '${fromPath}'.`,
1298
- );
1299
- }
1300
- }
1301
- await getCompanyUid(accessToken, slug);
1302
-
1303
- const result = await runCat({
1304
- key: keyArg,
1305
- out: options.out,
1306
- hqRoot: options.hqRoot,
1307
- companySlug: slug,
1308
- vaultClient: client,
1309
- companyClient: makeCompanyPresignFactory(accessToken),
1310
- region: DEFAULT_COGNITO.region,
1311
- });
1312
-
1313
- if (result.destination.kind === "file") {
1314
- console.error(
1315
- chalk.green("✓"),
1316
- `Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`,
1317
- );
1318
- }
1319
- } catch (err) {
1320
- console.error(
1321
- chalk.red("Error:"),
1322
- err instanceof Error ? err.message : String(err),
1323
- );
1324
- process.exit(1);
1325
- }
1326
- });
1327
-
1328
- filesCmd
1329
- .command("shared-with-me")
1330
- .description(
1331
- "List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).",
1332
- )
1333
- .option(
1334
- "--company <slug>",
1335
- "Scope to a single company (defaults to a cross-company roll-up).",
1336
- )
1337
- .action(async (options: { company?: string }, command: Command) => {
1338
- try {
1339
- // `--company` is bound on the parent `files` group — read merged opts.
1340
- const company = command.optsWithGlobals().company as string | undefined;
1341
- const accessToken = await ensureCognitoToken();
1342
- const vaultConfig = buildVaultConfig(accessToken);
1343
- const client = new VaultClient(vaultConfig);
1344
-
1345
- let companyUid: string | undefined;
1346
- if (company) {
1347
- // Confirm membership + resolve UID, same early-failure pattern as
1348
- // browse/cat. Roll-up mode skips this and fans out internally.
1349
- companyUid = await getCompanyUid(accessToken, company);
1350
- }
1351
-
1352
- const rows = await runSharedWithMe({
1353
- vaultClient: client,
1354
- companyUid,
1355
- companySlug: company,
1356
- });
1357
-
1358
- console.log(formatSharedWithMeTable(rows));
1359
- } catch (err) {
1360
- console.error(
1361
- chalk.red("Error:"),
1362
- err instanceof Error ? err.message : String(err),
1363
- );
1364
- process.exit(1);
1365
- }
1366
- });
1367
-
1368
- filesCmd
1369
- .command("search <query>")
1370
- .description(
1371
- "Search vault object keys (case-insensitive path/name match) under a company without downloading. Requires --company (or --personal). Content search is not supported in v1.",
1372
- )
1373
- .option("--company <slug>", "Company slug to search.")
1374
- .option(
1375
- "--personal",
1376
- "Search the caller's canonical personal vault. Mutually exclusive with --company.",
1377
- )
1378
- .action(async (query: string, options: FilesSearchCliOptions, command: Command) => {
1379
- try {
1380
- // `--company` is declared on the parent `files` group too, so commander
1381
- // binds it there; read the merged (global+local) opts to see it.
1382
- const company = command.optsWithGlobals().company as string | undefined;
1383
- if (options.personal && company) {
1384
- throw new Error(
1385
- "--personal and --company are mutually exclusive. Pick one.",
1386
- );
1387
- }
1388
-
1389
- const accessToken = await ensureCognitoToken();
1390
- const client = new VaultClient(buildVaultConfig(accessToken));
1391
-
1392
- if (options.personal) {
1393
- const personalUid = await resolveCanonicalPersonUid({
1394
- listMyMemberships: () => client.listMyMemberships(),
1395
- listPersonEntities: () => client.entity.listByType("person"),
1396
- getEntity: async () => null,
1397
- });
1398
- const rows = await runSearch({
1399
- query,
1400
- companySlug: "personal",
1401
- personalMode: true,
1402
- personalUid,
1403
- vaultClient: client,
1404
- s3Factory: defaultS3Factory,
1405
- region: DEFAULT_COGNITO.region,
1406
- });
1407
- console.log(formatBrowseTable(rows));
1408
- return;
1409
- }
1410
-
1411
- if (!company) {
1412
- throw new Error(
1413
- "search: --company <slug> is required (or --personal to search your personal vault).",
1414
- );
1415
- }
1416
- await getCompanyUid(accessToken, company);
1417
-
1418
- const rows = await runSearch({
1419
- query,
1420
- companySlug: company,
1421
- vaultClient: client,
1422
- companyClient: makeCompanyPresignFactory(accessToken),
1423
- region: DEFAULT_COGNITO.region,
1424
- });
1425
- console.log(formatBrowseTable(rows));
1426
- } catch (err) {
1427
- console.error(
1428
- chalk.red("Error:"),
1429
- err instanceof Error ? err.message : String(err),
1430
- );
1431
- process.exit(1);
1432
- }
1433
- });
1434
-
1435
- filesCmd
1436
- .command("get <path>")
1437
- .description(
1438
- "Download (materialize) a vault file or prefix into local HQ on demand. Default writes in place under <hqRoot>/companies/<slug>/<path> and registers a pin so a scoped sync (mode shared|custom) won't prune it. Use --into to write elsewhere (no pin). Company mode only.",
1439
- )
1440
- .option(
1441
- "--into <dir>",
1442
- "Write into this directory instead of the in-place companies/<slug>/ location. No pin is registered.",
1443
- )
1444
- .option(
1445
- "--company <slug>",
1446
- "Company slug (defaults to the slug parsed from <path>).",
1447
- )
1448
- .option(
1449
- "--hq-root <path>",
1450
- `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
1451
- DEFAULT_HQ_ROOT,
1452
- )
1453
- .action(async (pathArg: string, options: FilesGetCliOptions, command: Command) => {
1454
- try {
1455
- const accessToken = await ensureCognitoToken();
1456
- const client = new VaultClient(buildVaultConfig(accessToken));
1457
-
1458
- // `--company` is bound on the parent `files` group — read merged opts.
1459
- const companyOpt = command.optsWithGlobals().company as string | undefined;
1460
- const slug = companyOpt ?? parseCompanySlugFromPath(pathArg);
1461
- if (companyOpt !== undefined) {
1462
- const fromPath = (() => {
1463
- try {
1464
- return parseCompanySlugFromPath(pathArg);
1465
- } catch {
1466
- return undefined;
1467
- }
1468
- })();
1469
- if (fromPath && fromPath !== companyOpt) {
1470
- throw new Error(
1471
- `--company '${companyOpt}' disagrees with path slug '${fromPath}'.`,
1472
- );
1473
- }
1474
- }
1475
- await getCompanyUid(accessToken, slug);
1476
-
1477
- const result = await runGet({
1478
- path: pathArg,
1479
- into: options.into,
1480
- hqRoot: options.hqRoot,
1481
- companySlug: slug,
1482
- vaultClient: client,
1483
- companyClient: makeCompanyPresignFactory(accessToken),
1484
- region: DEFAULT_COGNITO.region,
1485
- });
1486
-
1487
- console.error(
1488
- chalk.green("✓"),
1489
- `Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`,
1490
- );
1491
- if (result.pinned) {
1492
- console.error(
1493
- chalk.dim(
1494
- `Pinned ${result.pinned.companySlug}:${result.pinned.prefix} — survives scoped sync.`,
1495
- ),
1496
- );
1497
- }
1498
- } catch (err) {
1499
- console.error(
1500
- chalk.red("Error:"),
1501
- err instanceof Error ? err.message : String(err),
1502
- );
1503
- process.exit(1);
1504
- }
1505
- });
1506
- }