@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
package/src/commands/files.ts
DELETED
|
@@ -1,1446 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import chalk from "chalk";
|
|
3
|
-
import open from "open";
|
|
4
|
-
import * as readline from "node:readline";
|
|
5
|
-
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
6
|
-
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
7
|
-
import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Pure helpers (exported for unit tests)
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Parse a human-friendly duration string ("15m", "1h", "24h", "2d") into
|
|
15
|
-
* milliseconds. Returns null on parse failure. Mirrors the parser used in
|
|
16
|
-
* `secrets generate-link` but kept local so files.ts can be tested in
|
|
17
|
-
* isolation without importing the much larger secrets command surface.
|
|
18
|
-
*/
|
|
19
|
-
export function parseDuration(input: string): number | null {
|
|
20
|
-
const match = input.match(/^(\d+)(m|h|d)$/);
|
|
21
|
-
if (!match) return null;
|
|
22
|
-
const value = parseInt(match[1], 10);
|
|
23
|
-
const unit = match[2];
|
|
24
|
-
if (unit === "m") return value * 60 * 1000;
|
|
25
|
-
if (unit === "h") return value * 60 * 60 * 1000;
|
|
26
|
-
if (unit === "d") return value * 24 * 60 * 60 * 1000;
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** PRD upper bound on browser-launch share-session expiry. */
|
|
31
|
-
export const MAX_SHARE_SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24h
|
|
32
|
-
|
|
33
|
-
export interface ShareSessionResponse {
|
|
34
|
-
url: string;
|
|
35
|
-
token?: string;
|
|
36
|
-
expiresAt: string;
|
|
37
|
-
nonce?: string;
|
|
38
|
-
paths?: string[];
|
|
39
|
-
maxPermissionByPath?: Record<string, string>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface MintShareSessionParams {
|
|
43
|
-
token: string;
|
|
44
|
-
companyUid: string;
|
|
45
|
-
paths: string[];
|
|
46
|
-
expiresInMs?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* POST /files/{companyUid}/share-session — mint a browser-launch share
|
|
51
|
-
* session token. Returns the parsed response. Throws ShareSessionHttpError
|
|
52
|
-
* with a status + actionable message for any non-2xx so callers can render
|
|
53
|
-
* a single consistent error path.
|
|
54
|
-
*/
|
|
55
|
-
export async function mintShareSession(
|
|
56
|
-
params: MintShareSessionParams,
|
|
57
|
-
): Promise<ShareSessionResponse> {
|
|
58
|
-
const body: Record<string, unknown> = { paths: params.paths };
|
|
59
|
-
if (params.expiresInMs != null) body.expiresInMs = params.expiresInMs;
|
|
60
|
-
|
|
61
|
-
const res = await vaultApiFetch({
|
|
62
|
-
token: params.token,
|
|
63
|
-
path: `/files/${encodeURIComponent(params.companyUid)}/share-session`,
|
|
64
|
-
method: "POST",
|
|
65
|
-
body,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
if (!res.ok) {
|
|
69
|
-
const errBody = (await res.json().catch(() => ({}))) as Record<
|
|
70
|
-
string,
|
|
71
|
-
string
|
|
72
|
-
>;
|
|
73
|
-
throw new ShareSessionHttpError(
|
|
74
|
-
res.status,
|
|
75
|
-
errBody.message ?? errBody.error ?? res.statusText,
|
|
76
|
-
errBody.path,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return (await res.json()) as ShareSessionResponse;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export class ShareSessionHttpError extends Error {
|
|
84
|
-
constructor(
|
|
85
|
-
public readonly status: number,
|
|
86
|
-
message: string,
|
|
87
|
-
public readonly path?: string,
|
|
88
|
-
) {
|
|
89
|
-
super(message);
|
|
90
|
-
this.name = "ShareSessionHttpError";
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Map a ShareSessionHttpError to user-facing copy. Centralizes the
|
|
96
|
-
* status → message mapping so the share command and any future caller
|
|
97
|
-
* stay in sync.
|
|
98
|
-
*/
|
|
99
|
-
export function formatShareSessionError(err: ShareSessionHttpError): string {
|
|
100
|
-
if (err.status === 401) {
|
|
101
|
-
return "Not authenticated — please run `hq login`";
|
|
102
|
-
}
|
|
103
|
-
if (err.status === 403) {
|
|
104
|
-
if (err.path) {
|
|
105
|
-
return `Not authorized to share '${err.path}' — you need read access on every path`;
|
|
106
|
-
}
|
|
107
|
-
return "Not authorized — you need to be a company member with read access on every path";
|
|
108
|
-
}
|
|
109
|
-
if (err.status === 400) {
|
|
110
|
-
return `Invalid request: ${err.message}`;
|
|
111
|
-
}
|
|
112
|
-
if (err.status >= 500) {
|
|
113
|
-
return `Server error: ${err.message}`;
|
|
114
|
-
}
|
|
115
|
-
return err.message || `Request failed (${err.status})`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
// Command registration
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
export function registerFilesCommand(program: Command): Command {
|
|
123
|
-
const files = program
|
|
124
|
-
.command("files")
|
|
125
|
-
.description("Manage file access controls in HQ vault")
|
|
126
|
-
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
127
|
-
|
|
128
|
-
files
|
|
129
|
-
.command("share [paths...]")
|
|
130
|
-
.description(
|
|
131
|
-
"Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.",
|
|
132
|
-
)
|
|
133
|
-
.option(
|
|
134
|
-
"--with <principal>",
|
|
135
|
-
"Email address, group id, or '@all' to share with every active company member",
|
|
136
|
-
)
|
|
137
|
-
.option("--permission <level>", "Permission level (only with --with): read | write")
|
|
138
|
-
.option(
|
|
139
|
-
"--full",
|
|
140
|
-
"Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.",
|
|
141
|
-
)
|
|
142
|
-
.option(
|
|
143
|
-
"--expires <duration>",
|
|
144
|
-
"Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.",
|
|
145
|
-
)
|
|
146
|
-
.option("--no-open", "Print the share-session URL but do not launch the browser")
|
|
147
|
-
.action(
|
|
148
|
-
async (
|
|
149
|
-
paths: string[],
|
|
150
|
-
opts: {
|
|
151
|
-
with?: string;
|
|
152
|
-
permission?: string;
|
|
153
|
-
full?: boolean;
|
|
154
|
-
expires?: string;
|
|
155
|
-
open: boolean;
|
|
156
|
-
},
|
|
157
|
-
) => {
|
|
158
|
-
try {
|
|
159
|
-
// Full-vault grant: a glob-safe affordance for "give this person the
|
|
160
|
-
// whole vault" so admins never have to quote a `*` (an unquoted glob
|
|
161
|
-
// expands to local filenames and instantly fails the one-prefix
|
|
162
|
-
// check). Maps to the single `*` wildcard grant, which the server
|
|
163
|
-
// coalesces to one policy entry — sidestepping the per-prefix STS
|
|
164
|
-
// session-policy budget. Defaults to write permission.
|
|
165
|
-
if (opts.full) {
|
|
166
|
-
if (opts.with === undefined) {
|
|
167
|
-
console.error(
|
|
168
|
-
chalk.red(
|
|
169
|
-
"--full grants whole-vault access to a principal and requires --with <principal>.",
|
|
170
|
-
),
|
|
171
|
-
);
|
|
172
|
-
process.exit(1);
|
|
173
|
-
}
|
|
174
|
-
if (paths && paths.length > 0) {
|
|
175
|
-
console.error(
|
|
176
|
-
chalk.red(
|
|
177
|
-
"--full grants the entire vault; do not also pass file paths.",
|
|
178
|
-
),
|
|
179
|
-
);
|
|
180
|
-
process.exit(1);
|
|
181
|
-
}
|
|
182
|
-
await runDirectGrant({
|
|
183
|
-
prefix: "*",
|
|
184
|
-
principal: opts.with,
|
|
185
|
-
permission: opts.permission ?? "write",
|
|
186
|
-
companySlug: files.opts().company as string | undefined,
|
|
187
|
-
fullVault: true,
|
|
188
|
-
});
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (!paths || paths.length === 0) {
|
|
193
|
-
console.error(
|
|
194
|
-
chalk.red("usage: hq files share <paths...> [--with <principal>]"),
|
|
195
|
-
);
|
|
196
|
-
process.exit(1);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Fork: --with present → existing direct-grant path. No --with →
|
|
200
|
-
// browser-launch share-session path. Per PRD, the direct-grant
|
|
201
|
-
// path is unchanged from US-001 and operates on a single prefix.
|
|
202
|
-
if (opts.with !== undefined) {
|
|
203
|
-
if (paths.length !== 1) {
|
|
204
|
-
console.error(
|
|
205
|
-
chalk.red(
|
|
206
|
-
"Direct grant (--with) takes exactly one prefix. Pass multiple paths only when minting a share-session URL.",
|
|
207
|
-
),
|
|
208
|
-
);
|
|
209
|
-
process.exit(1);
|
|
210
|
-
}
|
|
211
|
-
await runDirectGrant({
|
|
212
|
-
prefix: paths[0],
|
|
213
|
-
principal: opts.with,
|
|
214
|
-
permission: opts.permission,
|
|
215
|
-
companySlug: files.opts().company as string | undefined,
|
|
216
|
-
});
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
await runShareSession({
|
|
221
|
-
paths,
|
|
222
|
-
expires: opts.expires,
|
|
223
|
-
launchBrowser: opts.open,
|
|
224
|
-
companySlug: files.opts().company as string | undefined,
|
|
225
|
-
});
|
|
226
|
-
} catch (err) {
|
|
227
|
-
console.error(
|
|
228
|
-
chalk.red("Error:"),
|
|
229
|
-
err instanceof Error ? err.message : String(err),
|
|
230
|
-
);
|
|
231
|
-
process.exit(1);
|
|
232
|
-
}
|
|
233
|
-
},
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
files
|
|
237
|
-
.command("unshare <prefix>")
|
|
238
|
-
.description("Remove a file access grant")
|
|
239
|
-
.requiredOption(
|
|
240
|
-
"--with <principal>",
|
|
241
|
-
"Email address, group id, or '@all' to remove the company-wide grant",
|
|
242
|
-
)
|
|
243
|
-
.action(async (prefix: string, opts: { with: string }) => {
|
|
244
|
-
try {
|
|
245
|
-
const canonicalPrefix = normalizeFilePrefix(prefix);
|
|
246
|
-
|
|
247
|
-
const principal = opts.with;
|
|
248
|
-
const isAll = principal === "@all";
|
|
249
|
-
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
250
|
-
const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
|
|
251
|
-
if (!isAll && !isEmail && !isGroup) {
|
|
252
|
-
console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`));
|
|
253
|
-
process.exit(1);
|
|
254
|
-
}
|
|
255
|
-
const granteeType = isAll
|
|
256
|
-
? "company-wide"
|
|
257
|
-
: isEmail
|
|
258
|
-
? "email"
|
|
259
|
-
: "group";
|
|
260
|
-
const granteeId = isAll
|
|
261
|
-
? ""
|
|
262
|
-
: isEmail
|
|
263
|
-
? principal.trim().toLowerCase()
|
|
264
|
-
: principal;
|
|
265
|
-
const principalLabel = isAll ? "everyone in the company" : granteeId;
|
|
266
|
-
|
|
267
|
-
const token = await ensureCognitoToken();
|
|
268
|
-
const companySlug = files.opts().company as string | undefined;
|
|
269
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
270
|
-
|
|
271
|
-
const res = await vaultApiFetch({
|
|
272
|
-
token,
|
|
273
|
-
path: `/files/${encodeURIComponent(companyUid)}/acl/revoke`,
|
|
274
|
-
method: "POST",
|
|
275
|
-
body: { prefix: canonicalPrefix, granteeType, granteeId },
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
if (!res.ok) {
|
|
279
|
-
const body = await res.json().catch(() => ({})) as Record<string, string>;
|
|
280
|
-
if (res.status === 401) {
|
|
281
|
-
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
282
|
-
} else if (res.status === 403) {
|
|
283
|
-
console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
|
|
284
|
-
} else if (res.status === 404) {
|
|
285
|
-
// A 404 means ZERO rows were removed — never report a green success
|
|
286
|
-
// that implies a revoke happened. Distinguish the two causes:
|
|
287
|
-
// ACL_NOT_FOUND — the prefix has no ACL at all (nothing was ever
|
|
288
|
-
// shared here): genuinely, benignly absent.
|
|
289
|
-
// GRANT_NOT_FOUND (or an older server with no code) — the ACL
|
|
290
|
-
// exists but no grant matched this principal:
|
|
291
|
-
// surface it as a warning so a typo'd/stale
|
|
292
|
-
// principal isn't mistaken for a real removal.
|
|
293
|
-
if (body.code === "ACL_NOT_FOUND") {
|
|
294
|
-
console.log(
|
|
295
|
-
chalk.dim(`Nothing to remove — '${canonicalPrefix}' has no sharing grants.`),
|
|
296
|
-
);
|
|
297
|
-
} else {
|
|
298
|
-
console.warn(
|
|
299
|
-
chalk.yellow(
|
|
300
|
-
`No matching grant for ${principalLabel} on '${canonicalPrefix}' — nothing was removed.`,
|
|
301
|
-
),
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
return;
|
|
305
|
-
} else if (res.status >= 500) {
|
|
306
|
-
console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
|
|
307
|
-
} else {
|
|
308
|
-
console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
|
|
309
|
-
}
|
|
310
|
-
process.exit(1);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
console.log(chalk.green(`Removed grant for ${principalLabel} on '${canonicalPrefix}'`));
|
|
314
|
-
} catch (err) {
|
|
315
|
-
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
316
|
-
process.exit(1);
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
files
|
|
321
|
-
.command("acl <prefix>")
|
|
322
|
-
.description("Show the ACL (access control list) for a file prefix")
|
|
323
|
-
.action(async (prefix: string) => {
|
|
324
|
-
try {
|
|
325
|
-
const canonicalPrefix = normalizeFilePrefix(prefix);
|
|
326
|
-
|
|
327
|
-
const token = await ensureCognitoToken();
|
|
328
|
-
const companySlug = files.opts().company as string | undefined;
|
|
329
|
-
const companyUid = await getCompanyUid(token, companySlug);
|
|
330
|
-
|
|
331
|
-
// `/acl/tree` carries the prefix's own row metadata (directRow) and the
|
|
332
|
-
// caller's effectivePermission alongside direct/inherited/children, so
|
|
333
|
-
// a single request returns everything the "files acl" view needs.
|
|
334
|
-
const treeRes = await vaultApiFetch({
|
|
335
|
-
token,
|
|
336
|
-
path: `/files/${encodeURIComponent(companyUid)}/acl/tree`,
|
|
337
|
-
query: { prefix: canonicalPrefix },
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
if (!treeRes.ok) {
|
|
341
|
-
const body = (await treeRes.json().catch(() => ({}))) as Record<string, string>;
|
|
342
|
-
if (treeRes.status === 401) {
|
|
343
|
-
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
344
|
-
} else if (treeRes.status === 403) {
|
|
345
|
-
console.error(chalk.red("Not authorized to view this file prefix's ACL"));
|
|
346
|
-
} else if (treeRes.status >= 500) {
|
|
347
|
-
console.error(chalk.red(`Server error: ${body.error ?? treeRes.statusText}`));
|
|
348
|
-
} else {
|
|
349
|
-
console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
|
|
350
|
-
}
|
|
351
|
-
process.exit(1);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
type AclEntry = {
|
|
355
|
-
granteeType: string;
|
|
356
|
-
granteeId: string;
|
|
357
|
-
permission: string;
|
|
358
|
-
grantedBy: string;
|
|
359
|
-
grantedAt: string;
|
|
360
|
-
};
|
|
361
|
-
type TreeResponse = {
|
|
362
|
-
prefix: string;
|
|
363
|
-
direct: AclEntry[];
|
|
364
|
-
inherited: Array<AclEntry & { sourcePrefix: string }>;
|
|
365
|
-
children: Array<AclEntry & { sourcePrefix: string }>;
|
|
366
|
-
directRow: {
|
|
367
|
-
creatorUid: string;
|
|
368
|
-
open: boolean;
|
|
369
|
-
createdAt: string;
|
|
370
|
-
updatedAt: string;
|
|
371
|
-
} | null;
|
|
372
|
-
effectivePermission: string | null;
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
const tree = (await treeRes.json()) as TreeResponse;
|
|
376
|
-
const row = tree.directRow;
|
|
377
|
-
|
|
378
|
-
// No own row AND nothing inherited or granted below — preserve the
|
|
379
|
-
// original "no ACL record" exit path.
|
|
380
|
-
if (!row && tree.inherited.length === 0 && tree.children.length === 0) {
|
|
381
|
-
console.error(chalk.red(`No ACL record exists for '${canonicalPrefix}'`));
|
|
382
|
-
process.exit(1);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const aclStatus = row?.open ? "open" : "restricted";
|
|
386
|
-
|
|
387
|
-
console.log(chalk.green(`ACL for ${tree.prefix} (${aclStatus})`));
|
|
388
|
-
if (row) {
|
|
389
|
-
console.log(`Creator: ${row.creatorUid}`);
|
|
390
|
-
} else {
|
|
391
|
-
console.log(chalk.gray(
|
|
392
|
-
"No direct ACL row — access flows from the inherited/descendant grants below.",
|
|
393
|
-
));
|
|
394
|
-
}
|
|
395
|
-
if (tree.effectivePermission) {
|
|
396
|
-
console.log(`Your effective permission: ${tree.effectivePermission}`);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Display labels for grantee identifiers — `company-wide` entries
|
|
400
|
-
// store `granteeId === ""` on the wire, but a blank cell is confusing
|
|
401
|
-
// in tabular output, so we render a human-readable phrase instead.
|
|
402
|
-
function displayGrantee(e: { granteeType: string; granteeId: string }): string {
|
|
403
|
-
if (e.granteeType === "company-wide") return "Everyone in company";
|
|
404
|
-
return e.granteeId;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function printEntryTable(
|
|
408
|
-
rows: Array<AclEntry & { sourcePrefix?: string }>,
|
|
409
|
-
showSource: boolean,
|
|
410
|
-
): void {
|
|
411
|
-
const TYPE_W = Math.max(4, ...rows.map((e) => e.granteeType.length));
|
|
412
|
-
const GRANTEE_W = Math.max(7, ...rows.map((e) => displayGrantee(e).length));
|
|
413
|
-
const PERM_W = Math.max(10, ...rows.map((e) => e.permission.length));
|
|
414
|
-
const BY_W = Math.max(10, ...rows.map((e) => e.grantedBy.length));
|
|
415
|
-
const SRC_W = showSource
|
|
416
|
-
? Math.max(6, ...rows.map((e) => (e.sourcePrefix ?? "").length))
|
|
417
|
-
: 0;
|
|
418
|
-
const headerCols = [
|
|
419
|
-
"TYPE".padEnd(TYPE_W),
|
|
420
|
-
"GRANTEE".padEnd(GRANTEE_W),
|
|
421
|
-
"PERMISSION".padEnd(PERM_W),
|
|
422
|
-
"GRANTED_BY".padEnd(BY_W),
|
|
423
|
-
"GRANTED_AT",
|
|
424
|
-
];
|
|
425
|
-
if (showSource) headerCols.splice(4, 0, "SOURCE".padEnd(SRC_W));
|
|
426
|
-
console.log(chalk.bold(headerCols.join(" ")));
|
|
427
|
-
for (const e of rows) {
|
|
428
|
-
const grantedAt = e.grantedAt.slice(0, 10);
|
|
429
|
-
const cols = [
|
|
430
|
-
e.granteeType.padEnd(TYPE_W),
|
|
431
|
-
displayGrantee(e).padEnd(GRANTEE_W),
|
|
432
|
-
e.permission.padEnd(PERM_W),
|
|
433
|
-
e.grantedBy.padEnd(BY_W),
|
|
434
|
-
grantedAt,
|
|
435
|
-
];
|
|
436
|
-
if (showSource) cols.splice(4, 0, (e.sourcePrefix ?? "").padEnd(SRC_W));
|
|
437
|
-
console.log(cols.join(" "));
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const directEntries = tree.direct;
|
|
442
|
-
if (directEntries.length === 0) {
|
|
443
|
-
if (row?.open) {
|
|
444
|
-
console.log(chalk.gray("Open ACL — all active members have read access."));
|
|
445
|
-
} else if (row) {
|
|
446
|
-
console.log(chalk.gray("No explicit grants on this prefix — only creator has access."));
|
|
447
|
-
}
|
|
448
|
-
} else {
|
|
449
|
-
console.log("\nDirect entries (granted on this prefix):");
|
|
450
|
-
printEntryTable(directEntries, false);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (tree.inherited.length > 0) {
|
|
454
|
-
console.log("\nInherited (granted on an ancestor prefix):");
|
|
455
|
-
printEntryTable(tree.inherited, true);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (tree.children.length > 0) {
|
|
459
|
-
console.log("\nGranted on descendant prefixes (do not affect this prefix's access):");
|
|
460
|
-
printEntryTable(tree.children, true);
|
|
461
|
-
}
|
|
462
|
-
} catch (err) {
|
|
463
|
-
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
464
|
-
process.exit(1);
|
|
465
|
-
}
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
files
|
|
469
|
-
.command("delete <prefix>")
|
|
470
|
-
.description(
|
|
471
|
-
"Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes. Use --personal to target your own personal vault instead of a company.",
|
|
472
|
-
)
|
|
473
|
-
.option(
|
|
474
|
-
"--dry-run",
|
|
475
|
-
"List what WOULD be deleted without deleting anything",
|
|
476
|
-
)
|
|
477
|
-
.option(
|
|
478
|
-
"--personal",
|
|
479
|
-
"Target your own personal vault instead of a company vault (mutually exclusive with --company)",
|
|
480
|
-
)
|
|
481
|
-
.option("-y, --yes", "Skip the confirmation prompt (for scripts)")
|
|
482
|
-
.action(
|
|
483
|
-
async (
|
|
484
|
-
prefix: string,
|
|
485
|
-
opts: { dryRun?: boolean; yes?: boolean; personal?: boolean },
|
|
486
|
-
) => {
|
|
487
|
-
try {
|
|
488
|
-
const companySlug = files.opts().company as string | undefined;
|
|
489
|
-
const personal = opts.personal === true;
|
|
490
|
-
if (personal && companySlug) {
|
|
491
|
-
console.error(
|
|
492
|
-
chalk.red(
|
|
493
|
-
"Pass either --personal or --company, not both.",
|
|
494
|
-
),
|
|
495
|
-
);
|
|
496
|
-
process.exit(1);
|
|
497
|
-
}
|
|
498
|
-
await runFilesDelete({
|
|
499
|
-
prefix,
|
|
500
|
-
dryRun: opts.dryRun === true,
|
|
501
|
-
yes: opts.yes === true,
|
|
502
|
-
personal,
|
|
503
|
-
companySlug,
|
|
504
|
-
});
|
|
505
|
-
} catch (err) {
|
|
506
|
-
console.error(
|
|
507
|
-
chalk.red("Error:"),
|
|
508
|
-
err instanceof Error ? err.message : String(err),
|
|
509
|
-
);
|
|
510
|
-
process.exit(1);
|
|
511
|
-
}
|
|
512
|
-
},
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
files
|
|
516
|
-
.command("versions <path>")
|
|
517
|
-
.description(
|
|
518
|
-
"List prior content versions and delete markers for one exact vault key. Restore a listed version with restore --version-id <id>. Use --personal for your personal vault.",
|
|
519
|
-
)
|
|
520
|
-
.option(
|
|
521
|
-
"--personal",
|
|
522
|
-
"Target your own personal vault instead of a company vault (mutually exclusive with --company)",
|
|
523
|
-
)
|
|
524
|
-
.action(async (path: string, opts: { personal?: boolean }) => {
|
|
525
|
-
try {
|
|
526
|
-
const companySlug = files.opts().company as string | undefined;
|
|
527
|
-
const personal = opts.personal === true;
|
|
528
|
-
assertRecoveryScope(personal, companySlug);
|
|
529
|
-
await runFilesVersions({ key: path, personal, companySlug });
|
|
530
|
-
} catch (err) {
|
|
531
|
-
console.error(
|
|
532
|
-
chalk.red("Error:"),
|
|
533
|
-
err instanceof Error ? err.message : String(err),
|
|
534
|
-
);
|
|
535
|
-
process.exit(1);
|
|
536
|
-
}
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
files
|
|
540
|
-
.command("restore <path>")
|
|
541
|
-
.description(
|
|
542
|
-
"Restore a prior version with --version-id or undelete an exact vault key. Prompts before overwriting unless --yes. Use --personal for your personal vault.",
|
|
543
|
-
)
|
|
544
|
-
.option(
|
|
545
|
-
"--personal",
|
|
546
|
-
"Target your own personal vault instead of a company vault (mutually exclusive with --company)",
|
|
547
|
-
)
|
|
548
|
-
.option("--version-id <id>", "Content version ID to restore")
|
|
549
|
-
.option("-y, --yes", "Skip the overwrite confirmation prompt (for scripts)")
|
|
550
|
-
.action(
|
|
551
|
-
async (
|
|
552
|
-
path: string,
|
|
553
|
-
opts: { personal?: boolean; yes?: boolean; versionId?: string },
|
|
554
|
-
) => {
|
|
555
|
-
try {
|
|
556
|
-
const companySlug = files.opts().company as string | undefined;
|
|
557
|
-
const personal = opts.personal === true;
|
|
558
|
-
assertRecoveryScope(personal, companySlug);
|
|
559
|
-
await runFilesRestore({
|
|
560
|
-
key: path,
|
|
561
|
-
versionId: opts.versionId,
|
|
562
|
-
yes: opts.yes === true,
|
|
563
|
-
personal,
|
|
564
|
-
companySlug,
|
|
565
|
-
});
|
|
566
|
-
} catch (err) {
|
|
567
|
-
console.error(
|
|
568
|
-
chalk.red("Error:"),
|
|
569
|
-
err instanceof Error ? err.message : String(err),
|
|
570
|
-
);
|
|
571
|
-
process.exit(1);
|
|
572
|
-
}
|
|
573
|
-
},
|
|
574
|
-
);
|
|
575
|
-
|
|
576
|
-
files
|
|
577
|
-
.command("trash")
|
|
578
|
-
.description(
|
|
579
|
-
"List deleted vault keys retained as tombstones. Use --personal for your personal vault.",
|
|
580
|
-
)
|
|
581
|
-
.option(
|
|
582
|
-
"--personal",
|
|
583
|
-
"Target your own personal vault instead of a company vault (mutually exclusive with --company)",
|
|
584
|
-
)
|
|
585
|
-
.option("--prefix <prefix>", "Literal key prefix to filter tombstones")
|
|
586
|
-
.option("--cursor <cursor>", "Continue from an opaque tombstone cursor")
|
|
587
|
-
.action(
|
|
588
|
-
async (opts: { personal?: boolean; prefix?: string; cursor?: string }) => {
|
|
589
|
-
try {
|
|
590
|
-
const companySlug = files.opts().company as string | undefined;
|
|
591
|
-
const personal = opts.personal === true;
|
|
592
|
-
assertRecoveryScope(personal, companySlug);
|
|
593
|
-
await runFilesTrash({
|
|
594
|
-
prefix: opts.prefix ?? "",
|
|
595
|
-
cursor: opts.cursor,
|
|
596
|
-
personal,
|
|
597
|
-
companySlug,
|
|
598
|
-
});
|
|
599
|
-
} catch (err) {
|
|
600
|
-
console.error(
|
|
601
|
-
chalk.red("Error:"),
|
|
602
|
-
err instanceof Error ? err.message : String(err),
|
|
603
|
-
);
|
|
604
|
-
process.exit(1);
|
|
605
|
-
}
|
|
606
|
-
},
|
|
607
|
-
);
|
|
608
|
-
|
|
609
|
-
// Return the `files` Commander group so callers (src/index.ts) can attach
|
|
610
|
-
// additional subcommands (e.g. `hq files browse`/`hq files cat` from
|
|
611
|
-
// files-browse.ts) onto the same group without re-creating it.
|
|
612
|
-
return files;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// ---------------------------------------------------------------------------
|
|
616
|
-
// Direct-grant flow (existing US-001 behavior, refactored out of the action
|
|
617
|
-
// closure so the new share-session fork stays readable). Behavior unchanged.
|
|
618
|
-
// ---------------------------------------------------------------------------
|
|
619
|
-
|
|
620
|
-
interface DirectGrantParams {
|
|
621
|
-
prefix: string;
|
|
622
|
-
principal: string;
|
|
623
|
-
permission: string | undefined;
|
|
624
|
-
companySlug: string | undefined;
|
|
625
|
-
/**
|
|
626
|
-
* Set by the `--full` affordance: the grant targets the whole vault (the
|
|
627
|
-
* `*` wildcard prefix). Only affects the success message wording — the
|
|
628
|
-
* request shape is identical to any other prefix grant.
|
|
629
|
-
*/
|
|
630
|
-
fullVault?: boolean;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
async function runDirectGrant(params: DirectGrantParams): Promise<void> {
|
|
634
|
-
const canonicalPrefix = normalizeFilePrefix(params.prefix);
|
|
635
|
-
|
|
636
|
-
if (!params.permission) {
|
|
637
|
-
console.error(
|
|
638
|
-
chalk.red("--permission is required when --with is set (read | write)"),
|
|
639
|
-
);
|
|
640
|
-
process.exit(1);
|
|
641
|
-
}
|
|
642
|
-
if (!["read", "write"].includes(params.permission)) {
|
|
643
|
-
console.error(
|
|
644
|
-
chalk.red(`Invalid permission '${params.permission}': must be one of read, write`),
|
|
645
|
-
);
|
|
646
|
-
process.exit(1);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
const principal = params.principal;
|
|
650
|
-
// `@all` is the company-wide sentinel — maps to a single typed
|
|
651
|
-
// 'company-wide' ACL entry whose applicability is computed at resolve
|
|
652
|
-
// time from the active-member list. We send granteeId as the empty
|
|
653
|
-
// string to match the canonical storage form on the server.
|
|
654
|
-
const isAll = principal === "@all";
|
|
655
|
-
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
656
|
-
const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
|
|
657
|
-
if (!isAll && !isEmail && !isGroup) {
|
|
658
|
-
console.error(
|
|
659
|
-
chalk.red(
|
|
660
|
-
`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`,
|
|
661
|
-
),
|
|
662
|
-
);
|
|
663
|
-
process.exit(1);
|
|
664
|
-
}
|
|
665
|
-
const granteeType = isAll ? "company-wide" : isEmail ? "email" : "group";
|
|
666
|
-
const granteeId = isAll
|
|
667
|
-
? ""
|
|
668
|
-
: isEmail
|
|
669
|
-
? principal.trim().toLowerCase()
|
|
670
|
-
: principal;
|
|
671
|
-
// Display label used in success messages — `@all` reads better than `""`.
|
|
672
|
-
const principalLabel = isAll ? "everyone in the company" : granteeId;
|
|
673
|
-
|
|
674
|
-
const token = await ensureCognitoToken();
|
|
675
|
-
const companyUid = await getCompanyUid(token, params.companySlug);
|
|
676
|
-
|
|
677
|
-
let res = await vaultApiFetch({
|
|
678
|
-
token,
|
|
679
|
-
path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
|
|
680
|
-
method: "POST",
|
|
681
|
-
body: {
|
|
682
|
-
prefix: canonicalPrefix,
|
|
683
|
-
granteeType,
|
|
684
|
-
granteeId,
|
|
685
|
-
permission: params.permission,
|
|
686
|
-
},
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
// No ACL row exists yet for this prefix. Auto-create one with this
|
|
690
|
-
// grant as its first entry, then report success — saves the caller
|
|
691
|
-
// from needing a separate "create" step.
|
|
692
|
-
let autoCreated = false;
|
|
693
|
-
if (res.status === 404) {
|
|
694
|
-
res = await vaultApiFetch({
|
|
695
|
-
token,
|
|
696
|
-
path: `/files/${encodeURIComponent(companyUid)}/acl`,
|
|
697
|
-
method: "POST",
|
|
698
|
-
body: {
|
|
699
|
-
prefix: canonicalPrefix,
|
|
700
|
-
entries: [{ granteeType, granteeId, permission: params.permission }],
|
|
701
|
-
},
|
|
702
|
-
});
|
|
703
|
-
autoCreated = res.ok;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
if (!res.ok) {
|
|
707
|
-
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
708
|
-
if (res.status === 401) {
|
|
709
|
-
console.error(chalk.red("Not authenticated — please run `hq login`"));
|
|
710
|
-
} else if (res.status === 403) {
|
|
711
|
-
console.error(chalk.red("Not authorized to share this file prefix"));
|
|
712
|
-
} else if (res.status === 404) {
|
|
713
|
-
console.error(
|
|
714
|
-
chalk.red("ACL record not found — the prefix may not have an ACL yet"),
|
|
715
|
-
);
|
|
716
|
-
} else if (res.status === 409) {
|
|
717
|
-
console.error(chalk.red("Concurrent modification — please retry"));
|
|
718
|
-
} else if (res.status >= 500) {
|
|
719
|
-
console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
|
|
720
|
-
} else {
|
|
721
|
-
console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
|
|
722
|
-
}
|
|
723
|
-
process.exit(1);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
const data = (await res.json()) as {
|
|
727
|
-
acl?: { path?: string; prefix?: string };
|
|
728
|
-
};
|
|
729
|
-
const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
|
|
730
|
-
const verb = autoCreated ? "Created ACL and granted" : "Granted";
|
|
731
|
-
if (params.fullVault) {
|
|
732
|
-
console.log(
|
|
733
|
-
chalk.green(
|
|
734
|
-
`${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`,
|
|
735
|
-
),
|
|
736
|
-
);
|
|
737
|
-
} else {
|
|
738
|
-
console.log(
|
|
739
|
-
chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
|
|
740
|
-
);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// ---------------------------------------------------------------------------
|
|
745
|
-
// Share-session (browser-launch) flow — net-new for US-006
|
|
746
|
-
// ---------------------------------------------------------------------------
|
|
747
|
-
|
|
748
|
-
interface RunShareSessionParams {
|
|
749
|
-
paths: string[];
|
|
750
|
-
expires: string | undefined;
|
|
751
|
-
launchBrowser: boolean;
|
|
752
|
-
companySlug: string | undefined;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
async function runShareSession(params: RunShareSessionParams): Promise<void> {
|
|
756
|
-
// Normalize every path through the shared prefix helper so a trailing
|
|
757
|
-
// `/` becomes `/*` consistently with the direct-grant path.
|
|
758
|
-
const normalizedPaths = params.paths.map(normalizeFilePrefix);
|
|
759
|
-
|
|
760
|
-
let expiresInMs: number | undefined;
|
|
761
|
-
if (params.expires !== undefined) {
|
|
762
|
-
const parsed = parseDuration(params.expires);
|
|
763
|
-
if (parsed === null) {
|
|
764
|
-
console.error(
|
|
765
|
-
chalk.red(
|
|
766
|
-
`Invalid duration '${params.expires}'. Use formats like 15m, 1h, 24h.`,
|
|
767
|
-
),
|
|
768
|
-
);
|
|
769
|
-
process.exit(1);
|
|
770
|
-
}
|
|
771
|
-
if (parsed > MAX_SHARE_SESSION_EXPIRY_MS) {
|
|
772
|
-
console.error(
|
|
773
|
-
chalk.red(
|
|
774
|
-
"Maximum share-session expiry is 24h. For longer sharing, use `hq files share <prefix> --with <principal>`.",
|
|
775
|
-
),
|
|
776
|
-
);
|
|
777
|
-
process.exit(1);
|
|
778
|
-
}
|
|
779
|
-
expiresInMs = parsed;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
const token = await ensureCognitoToken();
|
|
783
|
-
const companyUid = await getCompanyUid(token, params.companySlug);
|
|
784
|
-
|
|
785
|
-
let session: ShareSessionResponse;
|
|
786
|
-
try {
|
|
787
|
-
session = await mintShareSession({
|
|
788
|
-
token,
|
|
789
|
-
companyUid,
|
|
790
|
-
paths: normalizedPaths,
|
|
791
|
-
expiresInMs,
|
|
792
|
-
});
|
|
793
|
-
} catch (err) {
|
|
794
|
-
if (err instanceof ShareSessionHttpError) {
|
|
795
|
-
console.error(chalk.red(formatShareSessionError(err)));
|
|
796
|
-
process.exit(1);
|
|
797
|
-
}
|
|
798
|
-
throw err;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
console.log(chalk.green("Share-session URL generated:"));
|
|
802
|
-
console.log(`\n ${session.url}\n`);
|
|
803
|
-
console.log(chalk.dim(` Paths: ${normalizedPaths.join(", ")}`));
|
|
804
|
-
console.log(chalk.dim(` Expires: ${session.expiresAt}`));
|
|
805
|
-
|
|
806
|
-
if (params.launchBrowser) {
|
|
807
|
-
// Best-effort browser launch — failures (no display, missing handler)
|
|
808
|
-
// shouldn't fail the command since the URL is already printed.
|
|
809
|
-
try {
|
|
810
|
-
await open(session.url);
|
|
811
|
-
} catch (err) {
|
|
812
|
-
console.error(
|
|
813
|
-
chalk.yellow(
|
|
814
|
-
`Couldn't launch browser automatically (${err instanceof Error ? err.message : String(err)}). Copy the URL above.`,
|
|
815
|
-
),
|
|
816
|
-
);
|
|
817
|
-
}
|
|
818
|
-
} else {
|
|
819
|
-
console.log(chalk.dim(" --no-open: copy the URL above to share manually."));
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// ---------------------------------------------------------------------------
|
|
824
|
-
// Scoped delete flow — `hq files delete <prefix>`
|
|
825
|
-
//
|
|
826
|
-
// Talks to the bounded server-side delete endpoint (POST /v1/files/delete).
|
|
827
|
-
// The server is the authority on scope + authorization + the actual S3 delete;
|
|
828
|
-
// this command is a careful front door:
|
|
829
|
-
// 1. normalize + reject the root/empty prefix CLIENT-side (defense in depth);
|
|
830
|
-
// 2. ALWAYS run a dry-run first to fetch + print the exact key count;
|
|
831
|
-
// 3. for a real delete, require confirmation unless --yes;
|
|
832
|
-
// 4. NEVER infer scope from local filesystem state.
|
|
833
|
-
// ---------------------------------------------------------------------------
|
|
834
|
-
|
|
835
|
-
/** Server response shape for POST /v1/files/delete. */
|
|
836
|
-
export interface FilesDeleteResponse {
|
|
837
|
-
prefix: string;
|
|
838
|
-
mode: "exact" | "prefix";
|
|
839
|
-
dryRun: boolean;
|
|
840
|
-
matched: number;
|
|
841
|
-
deleted: number;
|
|
842
|
-
skipped: number;
|
|
843
|
-
tombstoned: number;
|
|
844
|
-
keys: string[];
|
|
845
|
-
keysTruncated: boolean;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
export class FilesDeleteHttpError extends Error {
|
|
849
|
-
constructor(
|
|
850
|
-
public readonly status: number,
|
|
851
|
-
message: string,
|
|
852
|
-
public readonly code?: string,
|
|
853
|
-
) {
|
|
854
|
-
super(message);
|
|
855
|
-
this.name = "FilesDeleteHttpError";
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
/** Map a FilesDeleteHttpError to user-facing copy. */
|
|
860
|
-
export function formatFilesDeleteError(
|
|
861
|
-
err: FilesDeleteHttpError,
|
|
862
|
-
prefix: string,
|
|
863
|
-
): string {
|
|
864
|
-
if (err.status === 401) {
|
|
865
|
-
return "Not authenticated — please run `hq login`";
|
|
866
|
-
}
|
|
867
|
-
if (err.status === 403) {
|
|
868
|
-
return `Not authorized to delete '${prefix}' — you need write access on it`;
|
|
869
|
-
}
|
|
870
|
-
if (err.status === 400) {
|
|
871
|
-
return `Invalid request: ${err.message}`;
|
|
872
|
-
}
|
|
873
|
-
if (err.status >= 500) {
|
|
874
|
-
return `Server error: ${err.message}`;
|
|
875
|
-
}
|
|
876
|
-
return err.message || `Request failed (${err.status})`;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/** Injectable yes/no confirmation seam (stubbed in tests). */
|
|
880
|
-
export type ConfirmFn = (message: string) => Promise<boolean>;
|
|
881
|
-
|
|
882
|
-
function realConfirm(message: string): Promise<boolean> {
|
|
883
|
-
const rl = readline.createInterface({
|
|
884
|
-
input: process.stdin,
|
|
885
|
-
output: process.stdout,
|
|
886
|
-
});
|
|
887
|
-
return new Promise((resolve) => {
|
|
888
|
-
rl.question(`${message} [y/N] `, (answer) => {
|
|
889
|
-
rl.close();
|
|
890
|
-
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
891
|
-
});
|
|
892
|
-
});
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* POST /v1/files/delete. Throws FilesDeleteHttpError on any non-2xx so the one
|
|
897
|
-
* caller renders a single consistent error path.
|
|
898
|
-
*
|
|
899
|
-
* Scope is EITHER a company vault (`companyUid` set) OR the caller's personal
|
|
900
|
-
* vault (`personal: true`). For the personal case the server resolves the target
|
|
901
|
-
* person + bucket from the authenticated caller — we send NO uid, just the
|
|
902
|
-
* `personal` flag — so there is nothing for the client to get wrong or spoof.
|
|
903
|
-
*/
|
|
904
|
-
async function callDeleteEndpoint(params: {
|
|
905
|
-
token: string;
|
|
906
|
-
companyUid?: string;
|
|
907
|
-
personal?: boolean;
|
|
908
|
-
prefix: string;
|
|
909
|
-
dryRun: boolean;
|
|
910
|
-
}): Promise<FilesDeleteResponse> {
|
|
911
|
-
const res = await vaultApiFetch({
|
|
912
|
-
token: params.token,
|
|
913
|
-
path: "/v1/files/delete",
|
|
914
|
-
method: "POST",
|
|
915
|
-
body: params.personal
|
|
916
|
-
? {
|
|
917
|
-
personal: true,
|
|
918
|
-
prefix: params.prefix,
|
|
919
|
-
dryRun: params.dryRun,
|
|
920
|
-
}
|
|
921
|
-
: {
|
|
922
|
-
company: params.companyUid,
|
|
923
|
-
prefix: params.prefix,
|
|
924
|
-
dryRun: params.dryRun,
|
|
925
|
-
},
|
|
926
|
-
});
|
|
927
|
-
if (!res.ok) {
|
|
928
|
-
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
929
|
-
throw new FilesDeleteHttpError(
|
|
930
|
-
res.status,
|
|
931
|
-
body.message ?? body.error ?? res.statusText,
|
|
932
|
-
body.code,
|
|
933
|
-
);
|
|
934
|
-
}
|
|
935
|
-
return (await res.json()) as FilesDeleteResponse;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
interface RunFilesDeleteParams {
|
|
939
|
-
prefix: string;
|
|
940
|
-
dryRun: boolean;
|
|
941
|
-
yes: boolean;
|
|
942
|
-
/** Target the caller's personal vault instead of a company vault. */
|
|
943
|
-
personal?: boolean;
|
|
944
|
-
companySlug: string | undefined;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
/** How many would-delete keys to list before truncating the preview. */
|
|
948
|
-
const DELETE_PREVIEW_KEYS = 20;
|
|
949
|
-
|
|
950
|
-
function printKeyPreview(resp: FilesDeleteResponse): void {
|
|
951
|
-
const shown = resp.keys.slice(0, DELETE_PREVIEW_KEYS);
|
|
952
|
-
for (const key of shown) {
|
|
953
|
-
console.log(chalk.dim(` ${key}`));
|
|
954
|
-
}
|
|
955
|
-
const hiddenInList = resp.keys.length - shown.length;
|
|
956
|
-
if (hiddenInList > 0) {
|
|
957
|
-
console.log(chalk.dim(` … and ${hiddenInList} more`));
|
|
958
|
-
} else if (resp.keysTruncated) {
|
|
959
|
-
console.log(chalk.dim(` … and ${resp.matched - resp.keys.length} more`));
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
/**
|
|
964
|
-
* The vault bucket is already company-scoped, so a delete prefix must be
|
|
965
|
-
* BUCKET-RELATIVE (e.g. `projects/foo/*`). A caller who pastes an HQ *local*
|
|
966
|
-
* tree path (`companies/<slug>/projects/foo`) over-prefixes it; the server then
|
|
967
|
-
* rejects it with INVALID_PREFIX_COMPANIES_SCOPED (HTTP 400), the source of the
|
|
968
|
-
* recurring Sentry warning HQ-8F. Strip a redundant leading `companies/<slug>/`
|
|
969
|
-
* so the local-looking path is normalized to the bucket-relative key the vault
|
|
970
|
-
* actually stores. Returns the stripped slug for a one-line notice, or null when
|
|
971
|
-
* there was nothing to strip. Pure → unit-testable.
|
|
972
|
-
*
|
|
973
|
-
* This runs UPSTREAM of the server's exact-vs-glob branch, so it covers both
|
|
974
|
-
* the glob spelling (`companies/<slug>/projects/foo/*` → `validatePrefix`,
|
|
975
|
-
* HQ-8F) and the EXACT-key spelling (`companies/<slug>/notes/foo.md` →
|
|
976
|
-
* `validateObjectKey`, HQ-CA) with the same normalization.
|
|
977
|
-
*/
|
|
978
|
-
export function stripRedundantCompanyScope(
|
|
979
|
-
prefix: string,
|
|
980
|
-
): { prefix: string; strippedSlug: string } | null {
|
|
981
|
-
const m = /^companies\/([^/]+)(?:\/(.*))?$/.exec(prefix);
|
|
982
|
-
if (!m) return null;
|
|
983
|
-
return { prefix: m[2] ?? "", strippedSlug: m[1] };
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
export async function runFilesDelete(
|
|
987
|
-
params: RunFilesDeleteParams,
|
|
988
|
-
deps: { confirm?: ConfirmFn } = {},
|
|
989
|
-
): Promise<void> {
|
|
990
|
-
const confirm = deps.confirm ?? realConfirm;
|
|
991
|
-
|
|
992
|
-
// The vault is already company-scoped — a `companies/<slug>/` prefix is the HQ
|
|
993
|
-
// LOCAL tree layout, not a vault key, and the server 400s it (HQ-8F). Strip it
|
|
994
|
-
// here so a pasted local path is gracefully normalized to bucket-relative
|
|
995
|
-
// BEFORE the dry-run/preview (so the operator still sees the exact keys and
|
|
996
|
-
// confirms the right target). If stripping empties the prefix, the root-reject
|
|
997
|
-
// below catches it with a clear message.
|
|
998
|
-
const scope = stripRedundantCompanyScope(params.prefix);
|
|
999
|
-
if (scope) {
|
|
1000
|
-
console.error(
|
|
1001
|
-
chalk.yellow(
|
|
1002
|
-
`Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
|
|
1003
|
-
`is already company-scoped; using bucket-relative ` +
|
|
1004
|
-
`'${scope.prefix || "(root)"}'.`,
|
|
1005
|
-
),
|
|
1006
|
-
);
|
|
1007
|
-
}
|
|
1008
|
-
const rawPrefix = scope ? scope.prefix : params.prefix;
|
|
1009
|
-
|
|
1010
|
-
// Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
|
|
1011
|
-
// then reject the root/empty prefix CLIENT-side so a typo never reaches the
|
|
1012
|
-
// server as a vault-wide delete. The server enforces this too (defense in
|
|
1013
|
-
// depth), but failing fast here is clearer and avoids a wasted round-trip.
|
|
1014
|
-
const normalized = normalizeFilePrefix(rawPrefix);
|
|
1015
|
-
if (normalized === "" || normalized === "*" || normalized === "/*") {
|
|
1016
|
-
console.error(
|
|
1017
|
-
chalk.red(
|
|
1018
|
-
"Refusing to delete the vault root. Pass a bounded prefix (e.g. 'projects/foo/' or 'projects/foo/*') or an exact key.",
|
|
1019
|
-
),
|
|
1020
|
-
);
|
|
1021
|
-
process.exit(1);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
const token = await ensureCognitoToken();
|
|
1025
|
-
// Personal scope resolves the vault server-side from the caller's identity —
|
|
1026
|
-
// no company to look up. Company scope resolves the companyUid as before.
|
|
1027
|
-
const companyUid = params.personal
|
|
1028
|
-
? undefined
|
|
1029
|
-
: await getCompanyUid(token, params.companySlug);
|
|
1030
|
-
const scopeArgs = params.personal
|
|
1031
|
-
? { personal: true as const }
|
|
1032
|
-
: { companyUid };
|
|
1033
|
-
|
|
1034
|
-
// 1. Always preview first — this is how we print the EXACT key count before
|
|
1035
|
-
// deleting anything (and the whole behavior of --dry-run).
|
|
1036
|
-
let preview: FilesDeleteResponse;
|
|
1037
|
-
try {
|
|
1038
|
-
preview = await callDeleteEndpoint({
|
|
1039
|
-
token,
|
|
1040
|
-
...scopeArgs,
|
|
1041
|
-
prefix: normalized,
|
|
1042
|
-
dryRun: true,
|
|
1043
|
-
});
|
|
1044
|
-
} catch (err) {
|
|
1045
|
-
if (err instanceof FilesDeleteHttpError) {
|
|
1046
|
-
console.error(chalk.red(formatFilesDeleteError(err, normalized)));
|
|
1047
|
-
process.exit(1);
|
|
1048
|
-
}
|
|
1049
|
-
throw err;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
if (preview.matched === 0) {
|
|
1053
|
-
console.log(chalk.dim(`Nothing to delete under '${normalized}'.`));
|
|
1054
|
-
return;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
const noun = preview.matched === 1 ? "object" : "objects";
|
|
1058
|
-
|
|
1059
|
-
if (params.dryRun) {
|
|
1060
|
-
console.log(
|
|
1061
|
-
chalk.green(
|
|
1062
|
-
`[dry-run] Would delete ${preview.matched} ${noun} under '${normalized}':`,
|
|
1063
|
-
),
|
|
1064
|
-
);
|
|
1065
|
-
printKeyPreview(preview);
|
|
1066
|
-
if (preview.skipped > 0) {
|
|
1067
|
-
console.log(
|
|
1068
|
-
chalk.dim(
|
|
1069
|
-
` (${preview.skipped} more under this prefix you can't delete would be left untouched)`,
|
|
1070
|
-
),
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
console.log(chalk.dim("Nothing was deleted (--dry-run)."));
|
|
1074
|
-
return;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
// 2. Real delete — show what's about to happen, then confirm.
|
|
1078
|
-
console.log(
|
|
1079
|
-
chalk.yellow(`About to delete ${preview.matched} ${noun} under '${normalized}':`),
|
|
1080
|
-
);
|
|
1081
|
-
printKeyPreview(preview);
|
|
1082
|
-
if (preview.skipped > 0) {
|
|
1083
|
-
console.log(
|
|
1084
|
-
chalk.dim(
|
|
1085
|
-
` (${preview.skipped} more under this prefix you can't delete will be left untouched)`,
|
|
1086
|
-
),
|
|
1087
|
-
);
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
if (!params.yes) {
|
|
1091
|
-
const ok = await confirm(
|
|
1092
|
-
params.personal
|
|
1093
|
-
? `Delete ${preview.matched} ${noun}? This removes them from your personal vault.`
|
|
1094
|
-
: `Delete ${preview.matched} ${noun}? This removes them from the shared vault for everyone.`,
|
|
1095
|
-
);
|
|
1096
|
-
if (!ok) {
|
|
1097
|
-
console.log(chalk.dim("Aborted — nothing was deleted."));
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// 3. Execute the delete.
|
|
1103
|
-
let result: FilesDeleteResponse;
|
|
1104
|
-
try {
|
|
1105
|
-
result = await callDeleteEndpoint({
|
|
1106
|
-
token,
|
|
1107
|
-
...scopeArgs,
|
|
1108
|
-
prefix: normalized,
|
|
1109
|
-
dryRun: false,
|
|
1110
|
-
});
|
|
1111
|
-
} catch (err) {
|
|
1112
|
-
if (err instanceof FilesDeleteHttpError) {
|
|
1113
|
-
console.error(chalk.red(formatFilesDeleteError(err, normalized)));
|
|
1114
|
-
process.exit(1);
|
|
1115
|
-
}
|
|
1116
|
-
throw err;
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
const deletedNoun = result.deleted === 1 ? "object" : "objects";
|
|
1120
|
-
console.log(
|
|
1121
|
-
chalk.green(`Deleted ${result.deleted} ${deletedNoun} under '${normalized}'.`),
|
|
1122
|
-
);
|
|
1123
|
-
if (result.skipped > 0) {
|
|
1124
|
-
console.log(
|
|
1125
|
-
chalk.dim(
|
|
1126
|
-
`${result.skipped} object(s) under this prefix you can't delete were left untouched.`,
|
|
1127
|
-
),
|
|
1128
|
-
);
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// ---------------------------------------------------------------------------
|
|
1133
|
-
// Version history + recovery commands
|
|
1134
|
-
// ---------------------------------------------------------------------------
|
|
1135
|
-
|
|
1136
|
-
export interface FileVersionRow {
|
|
1137
|
-
versionId: string;
|
|
1138
|
-
isLatest: boolean;
|
|
1139
|
-
lastModified: string;
|
|
1140
|
-
size: number;
|
|
1141
|
-
isDeleteMarker?: boolean;
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
export interface FilesVersionsResponse {
|
|
1145
|
-
key: string;
|
|
1146
|
-
versions: FileVersionRow[];
|
|
1147
|
-
computedAt: string;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
export interface FileTombstoneRow {
|
|
1151
|
-
key: string;
|
|
1152
|
-
deletedAt: string;
|
|
1153
|
-
deletedBy: string;
|
|
1154
|
-
deletedPrefix: string;
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
export interface FilesTrashResponse {
|
|
1158
|
-
companyUid?: string;
|
|
1159
|
-
personal?: true;
|
|
1160
|
-
tombstones: FileTombstoneRow[];
|
|
1161
|
-
cursor?: string | null;
|
|
1162
|
-
truncated: boolean;
|
|
1163
|
-
computedAt: string;
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
export interface FilesRestoreResponse {
|
|
1167
|
-
key: string;
|
|
1168
|
-
wasDeleted: boolean;
|
|
1169
|
-
restoredFromVersionId: string;
|
|
1170
|
-
newVersionId: string;
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
export class FilesRecoveryHttpError extends Error {
|
|
1174
|
-
constructor(
|
|
1175
|
-
public readonly status: number,
|
|
1176
|
-
message: string,
|
|
1177
|
-
public readonly code?: string,
|
|
1178
|
-
) {
|
|
1179
|
-
super(message);
|
|
1180
|
-
this.name = "FilesRecoveryHttpError";
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
function assertRecoveryScope(personal: boolean, companySlug: string | undefined): void {
|
|
1185
|
-
if (personal && companySlug) {
|
|
1186
|
-
throw new Error("Pass either --personal or --company, not both.");
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
function assertExactRecoveryKey(key: string): void {
|
|
1191
|
-
if (!key || key.includes("*")) {
|
|
1192
|
-
throw new Error(
|
|
1193
|
-
"Restore and version history require one exact, wildcard-free vault key.",
|
|
1194
|
-
);
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
function quoteRecoveryShellArg(value: string): string {
|
|
1199
|
-
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
function formatBytes(bytes: number): string {
|
|
1203
|
-
if (bytes < 1024) return String(bytes) + " B";
|
|
1204
|
-
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB";
|
|
1205
|
-
if (bytes < 1024 * 1024 * 1024) {
|
|
1206
|
-
return (bytes / (1024 * 1024)).toFixed(1) + " MiB";
|
|
1207
|
-
}
|
|
1208
|
-
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GiB";
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
/** Render the exact-key version history, including S3 delete markers. */
|
|
1212
|
-
export function formatFilesVersionsTable(rows: FileVersionRow[]): string {
|
|
1213
|
-
if (rows.length === 0) {
|
|
1214
|
-
return "No version history exists for that key.";
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
const cols = ["VERSION", "TYPE", "LATEST", "MODIFIED", "SIZE"];
|
|
1218
|
-
const data = rows.map((row) => [
|
|
1219
|
-
row.versionId,
|
|
1220
|
-
row.isDeleteMarker ? "delete marker" : "content",
|
|
1221
|
-
row.isLatest ? "yes" : "",
|
|
1222
|
-
row.lastModified,
|
|
1223
|
-
row.isDeleteMarker ? "—" : formatBytes(row.size),
|
|
1224
|
-
]);
|
|
1225
|
-
const widths = cols.map((col, index) =>
|
|
1226
|
-
Math.max(col.length, ...data.map((row) => row[index].length)),
|
|
1227
|
-
);
|
|
1228
|
-
const renderRow = (row: string[]): string =>
|
|
1229
|
-
row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
1230
|
-
return [
|
|
1231
|
-
chalk.bold(renderRow(cols)),
|
|
1232
|
-
chalk.dim(renderRow(widths.map((width) => "─".repeat(width)))),
|
|
1233
|
-
...data.map(renderRow),
|
|
1234
|
-
].join("\n");
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
/** Render durable delete tombstones without implying the objects still exist. */
|
|
1238
|
-
export function formatFilesTrashTable(rows: FileTombstoneRow[]): string {
|
|
1239
|
-
if (rows.length === 0) {
|
|
1240
|
-
return "Trash is empty.";
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
const cols = ["KEY", "DELETED", "DELETED BY", "DELETE PREFIX"];
|
|
1244
|
-
const data = rows.map((row) => [
|
|
1245
|
-
row.key,
|
|
1246
|
-
row.deletedAt,
|
|
1247
|
-
row.deletedBy,
|
|
1248
|
-
row.deletedPrefix,
|
|
1249
|
-
]);
|
|
1250
|
-
const widths = cols.map((col, index) =>
|
|
1251
|
-
Math.max(col.length, ...data.map((row) => row[index].length)),
|
|
1252
|
-
);
|
|
1253
|
-
const renderRow = (row: string[]): string =>
|
|
1254
|
-
row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
1255
|
-
return [
|
|
1256
|
-
chalk.bold(renderRow(cols)),
|
|
1257
|
-
chalk.dim(renderRow(widths.map((width) => "─".repeat(width)))),
|
|
1258
|
-
...data.map(renderRow),
|
|
1259
|
-
].join("\n");
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
function throwRecoveryHttpError(
|
|
1263
|
-
status: number,
|
|
1264
|
-
body: Record<string, string>,
|
|
1265
|
-
statusText: string,
|
|
1266
|
-
): never {
|
|
1267
|
-
throw new FilesRecoveryHttpError(
|
|
1268
|
-
status,
|
|
1269
|
-
body.message ?? body.error ?? statusText,
|
|
1270
|
-
body.code,
|
|
1271
|
-
);
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
function formatFilesRecoveryError(err: FilesRecoveryHttpError, key?: string): string {
|
|
1275
|
-
if (err.status === 401) {
|
|
1276
|
-
return "Not authenticated — please run hq login";
|
|
1277
|
-
}
|
|
1278
|
-
if (err.status === 403) {
|
|
1279
|
-
return key
|
|
1280
|
-
? "Not authorized to modify '" + key + "' — you need write access on it"
|
|
1281
|
-
: "Not authorized to view this vault path";
|
|
1282
|
-
}
|
|
1283
|
-
if (err.code === "FILES_RESTORE_VERSION_NOT_FOUND") {
|
|
1284
|
-
return "That version is unavailable or is a delete marker.";
|
|
1285
|
-
}
|
|
1286
|
-
if (err.code === "FILES_RESTORE_NO_PRIOR_VERSION") {
|
|
1287
|
-
return "There is no prior content version to restore.";
|
|
1288
|
-
}
|
|
1289
|
-
if (err.code === "FILES_RESTORE_NO_RECOVERABLE_VERSION") {
|
|
1290
|
-
return "There is no recoverable content version for this key.";
|
|
1291
|
-
}
|
|
1292
|
-
if (err.status === 400) {
|
|
1293
|
-
return "Invalid request: " + err.message;
|
|
1294
|
-
}
|
|
1295
|
-
if (err.status >= 500) {
|
|
1296
|
-
return "Server error: " + err.message;
|
|
1297
|
-
}
|
|
1298
|
-
return err.message || "Request failed (" + String(err.status) + ")";
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
interface RecoveryScope {
|
|
1302
|
-
token: string;
|
|
1303
|
-
companyUid?: string;
|
|
1304
|
-
personal: boolean;
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
async function resolveRecoveryScope(params: {
|
|
1308
|
-
personal: boolean;
|
|
1309
|
-
companySlug: string | undefined;
|
|
1310
|
-
}): Promise<RecoveryScope> {
|
|
1311
|
-
assertRecoveryScope(params.personal, params.companySlug);
|
|
1312
|
-
const token = await ensureCognitoToken();
|
|
1313
|
-
if (params.personal) {
|
|
1314
|
-
return { token, personal: true };
|
|
1315
|
-
}
|
|
1316
|
-
return {
|
|
1317
|
-
token,
|
|
1318
|
-
personal: false,
|
|
1319
|
-
companyUid: await getCompanyUid(token, params.companySlug),
|
|
1320
|
-
};
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
export async function runFilesVersions(params: {
|
|
1324
|
-
key: string;
|
|
1325
|
-
personal: boolean;
|
|
1326
|
-
companySlug: string | undefined;
|
|
1327
|
-
}): Promise<FilesVersionsResponse> {
|
|
1328
|
-
assertExactRecoveryKey(params.key);
|
|
1329
|
-
const scope = await resolveRecoveryScope(params);
|
|
1330
|
-
const res = await vaultApiFetch({
|
|
1331
|
-
token: scope.token,
|
|
1332
|
-
path: "/v1/files/versions",
|
|
1333
|
-
query: scope.personal
|
|
1334
|
-
? { personal: "1", key: params.key }
|
|
1335
|
-
: { company: scope.companyUid!, key: params.key },
|
|
1336
|
-
});
|
|
1337
|
-
if (!res.ok) {
|
|
1338
|
-
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
1339
|
-
throwRecoveryHttpError(res.status, body, res.statusText);
|
|
1340
|
-
}
|
|
1341
|
-
const data = (await res.json()) as FilesVersionsResponse;
|
|
1342
|
-
console.log(chalk.green("Versions for '" + data.key + "':"));
|
|
1343
|
-
console.log(formatFilesVersionsTable(data.versions));
|
|
1344
|
-
return data;
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
export async function runFilesRestore(
|
|
1348
|
-
params: {
|
|
1349
|
-
key: string;
|
|
1350
|
-
versionId?: string;
|
|
1351
|
-
yes: boolean;
|
|
1352
|
-
personal: boolean;
|
|
1353
|
-
companySlug: string | undefined;
|
|
1354
|
-
},
|
|
1355
|
-
deps: { confirm?: ConfirmFn } = {},
|
|
1356
|
-
): Promise<FilesRestoreResponse | undefined> {
|
|
1357
|
-
assertExactRecoveryKey(params.key);
|
|
1358
|
-
if (params.versionId !== undefined && params.versionId.trim() === "") {
|
|
1359
|
-
throw new Error("versionId must not be empty.");
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
const confirm = deps.confirm ?? realConfirm;
|
|
1363
|
-
if (!params.yes) {
|
|
1364
|
-
const ok = await confirm(
|
|
1365
|
-
params.personal
|
|
1366
|
-
? "Restore '" + params.key + "' in your personal vault? This overwrites current content."
|
|
1367
|
-
: "Restore '" + params.key + "'? This overwrites current content in the shared vault.",
|
|
1368
|
-
);
|
|
1369
|
-
if (!ok) {
|
|
1370
|
-
console.log(chalk.dim("Aborted — nothing was restored."));
|
|
1371
|
-
return undefined;
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
const scope = await resolveRecoveryScope(params);
|
|
1376
|
-
const body: Record<string, unknown> = scope.personal
|
|
1377
|
-
? { personal: true, key: params.key }
|
|
1378
|
-
: { company: scope.companyUid, key: params.key };
|
|
1379
|
-
if (params.versionId !== undefined) body.versionId = params.versionId;
|
|
1380
|
-
const res = await vaultApiFetch({
|
|
1381
|
-
token: scope.token,
|
|
1382
|
-
path: "/v1/files/restore",
|
|
1383
|
-
method: "POST",
|
|
1384
|
-
body,
|
|
1385
|
-
});
|
|
1386
|
-
if (!res.ok) {
|
|
1387
|
-
const errBody = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
1388
|
-
const err = new FilesRecoveryHttpError(
|
|
1389
|
-
res.status,
|
|
1390
|
-
errBody.message ?? errBody.error ?? res.statusText,
|
|
1391
|
-
errBody.code,
|
|
1392
|
-
);
|
|
1393
|
-
throw new Error(formatFilesRecoveryError(err, params.key));
|
|
1394
|
-
}
|
|
1395
|
-
const data = (await res.json()) as FilesRestoreResponse;
|
|
1396
|
-
const verb = data.wasDeleted ? "Undeleted" : "Restored";
|
|
1397
|
-
console.log(
|
|
1398
|
-
chalk.green(
|
|
1399
|
-
verb + " '" + data.key + "' from version '" + data.restoredFromVersionId + "'.",
|
|
1400
|
-
),
|
|
1401
|
-
);
|
|
1402
|
-
console.log(chalk.dim("New version: " + data.newVersionId));
|
|
1403
|
-
return data;
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
export async function runFilesTrash(params: {
|
|
1407
|
-
prefix: string;
|
|
1408
|
-
cursor?: string;
|
|
1409
|
-
personal: boolean;
|
|
1410
|
-
companySlug: string | undefined;
|
|
1411
|
-
}): Promise<FilesTrashResponse> {
|
|
1412
|
-
if (params.prefix.includes("*")) {
|
|
1413
|
-
throw new Error("Trash prefix must be literal; wildcard expansion is not supported.");
|
|
1414
|
-
}
|
|
1415
|
-
const scope = await resolveRecoveryScope(params);
|
|
1416
|
-
const query: Record<string, string> = scope.personal
|
|
1417
|
-
? { personal: "1" }
|
|
1418
|
-
: { company: scope.companyUid! };
|
|
1419
|
-
if (params.prefix) query.prefix = params.prefix;
|
|
1420
|
-
if (params.cursor) query.cursor = params.cursor;
|
|
1421
|
-
const res = await vaultApiFetch({
|
|
1422
|
-
token: scope.token,
|
|
1423
|
-
path: "/v1/files/tombstones",
|
|
1424
|
-
query,
|
|
1425
|
-
});
|
|
1426
|
-
if (!res.ok) {
|
|
1427
|
-
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
1428
|
-
throwRecoveryHttpError(res.status, body, res.statusText);
|
|
1429
|
-
}
|
|
1430
|
-
const data = (await res.json()) as FilesTrashResponse;
|
|
1431
|
-
console.log(formatFilesTrashTable(data.tombstones));
|
|
1432
|
-
if (data.truncated && data.cursor) {
|
|
1433
|
-
let continuation = "hq files";
|
|
1434
|
-
if (params.companySlug) {
|
|
1435
|
-
continuation += " --company " + quoteRecoveryShellArg(params.companySlug);
|
|
1436
|
-
}
|
|
1437
|
-
continuation += " trash";
|
|
1438
|
-
if (params.personal) continuation += " --personal";
|
|
1439
|
-
if (params.prefix) {
|
|
1440
|
-
continuation += " --prefix " + quoteRecoveryShellArg(params.prefix);
|
|
1441
|
-
}
|
|
1442
|
-
continuation += " --cursor " + quoteRecoveryShellArg(data.cursor);
|
|
1443
|
-
console.log(chalk.dim("More results: " + continuation));
|
|
1444
|
-
}
|
|
1445
|
-
return data;
|
|
1446
|
-
}
|