@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.
- package/CHANGELOG.md +34 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +51 -2
- package/dist/commands/pkg-install.js +43 -0
- package/dist/commands/run.js +8 -0
- package/dist/commands/secrets.js +125 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2247
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -71
- package/src/commands/pkg-install.ts +0 -215
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -184
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2037
- package/src/commands/secrets.ts +0 -2240
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- 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
|
-
}
|