@indigoai-us/hq-cli 5.77.14 → 5.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +42 -2
  33. package/dist/commands/pkg-install.js +39 -2
  34. package/dist/commands/run.js +6 -2
  35. package/dist/commands/secrets.js +120 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2256
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs-update-api-key.test.ts +0 -105
  146. package/src/commands/packs.test.ts +0 -88
  147. package/src/commands/packs.ts +0 -622
  148. package/src/commands/people.test.ts +0 -807
  149. package/src/commands/people.ts +0 -422
  150. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  151. package/src/commands/pkg-install.ts +0 -221
  152. package/src/commands/pkg-list.test.ts +0 -79
  153. package/src/commands/pkg-list.ts +0 -13
  154. package/src/commands/pkg-remove.ts +0 -71
  155. package/src/commands/pkg-update.ts +0 -189
  156. package/src/commands/publish.test.ts +0 -650
  157. package/src/commands/publish.ts +0 -580
  158. package/src/commands/reindex.test.ts +0 -259
  159. package/src/commands/reindex.ts +0 -274
  160. package/src/commands/rescue.test.ts +0 -39
  161. package/src/commands/rescue.ts +0 -210
  162. package/src/commands/run.env-local.test.ts +0 -88
  163. package/src/commands/run.test.ts +0 -45
  164. package/src/commands/run.ts +0 -190
  165. package/src/commands/safe-extract.test.ts +0 -459
  166. package/src/commands/safe-extract.ts +0 -444
  167. package/src/commands/secrets-scope.test.ts +0 -56
  168. package/src/commands/secrets-scope.ts +0 -32
  169. package/src/commands/secrets.parse-destination.test.ts +0 -38
  170. package/src/commands/secrets.test.ts +0 -2050
  171. package/src/commands/secrets.ts +0 -2249
  172. package/src/commands/signals.ts +0 -351
  173. package/src/commands/skill.test.ts +0 -770
  174. package/src/commands/skill.ts +0 -796
  175. package/src/commands/sources.ts +0 -362
  176. package/src/commands/sync-mode.test.ts +0 -366
  177. package/src/commands/sync-mode.ts +0 -387
  178. package/src/commands/sync-narrow.test.ts +0 -633
  179. package/src/commands/sync-narrow.ts +0 -555
  180. package/src/commands/sync.ts +0 -165
  181. package/src/commands/team-sync.ts +0 -628
  182. package/src/commands/update.ts +0 -71
  183. package/src/commands/whoami.ts +0 -83
  184. package/src/commands/workers.test.ts +0 -158
  185. package/src/commands/workers.ts +0 -301
  186. package/src/index.test.ts +0 -32
  187. package/src/index.ts +0 -20
  188. package/src/lib/db/control-plane.test.ts +0 -59
  189. package/src/lib/db/control-plane.ts +0 -113
  190. package/src/lib/db/local.test.ts +0 -81
  191. package/src/lib/db/local.ts +0 -148
  192. package/src/lib/db/migrate.test.ts +0 -133
  193. package/src/lib/db/migrate.ts +0 -137
  194. package/src/lib/db/paths.test.ts +0 -112
  195. package/src/lib/db/paths.ts +0 -128
  196. package/src/lib/db/remote-engine.test.ts +0 -44
  197. package/src/lib/db/remote-engine.ts +0 -148
  198. package/src/lib/db/remote-sql.test.ts +0 -32
  199. package/src/lib/db/remote-sql.ts +0 -62
  200. package/src/lib/db/sql.test.ts +0 -106
  201. package/src/lib/db/sql.ts +0 -192
  202. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  203. package/src/lib/local-tree-diff.test.ts +0 -301
  204. package/src/lib/local-tree-diff.ts +0 -362
  205. package/src/lib/narrow-hint-banner.test.ts +0 -235
  206. package/src/lib/narrow-hint-banner.ts +0 -212
  207. package/src/lib/plan-limit-nag.test.ts +0 -317
  208. package/src/lib/plan-limit-nag.ts +0 -264
  209. package/src/main.ts +0 -371
  210. package/src/node-preflight.test.ts +0 -60
  211. package/src/node-preflight.ts +0 -67
  212. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  213. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  214. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  215. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  216. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  217. package/src/outpost/session-heartbeat.test.ts +0 -459
  218. package/src/outpost/session-heartbeat.ts +0 -877
  219. package/src/packaging.test.ts +0 -109
  220. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  221. package/src/run/discover-schemas.test.ts +0 -153
  222. package/src/run/discover-schemas.ts +0 -79
  223. package/src/run/hq-plugin.test.ts +0 -371
  224. package/src/run/hq-plugin.ts +0 -277
  225. package/src/run/varlock-shape.test.ts +0 -57
  226. package/src/schemas/hq-package.schema.json +0 -155
  227. package/src/sentry-before-send.test.ts +0 -359
  228. package/src/sentry-before-send.ts +0 -221
  229. package/src/sentry-epipe.test.ts +0 -37
  230. package/src/sentry-release.test.ts +0 -54
  231. package/src/sentry.test.ts +0 -24
  232. package/src/sentry.ts +0 -54
  233. package/src/strategies/link.ts +0 -62
  234. package/src/strategies/merge.ts +0 -142
  235. package/src/types.ts +0 -146
  236. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  237. package/src/utils/auth-error.test.ts +0 -40
  238. package/src/utils/auth-error.ts +0 -42
  239. package/src/utils/billing-gate.test.ts +0 -95
  240. package/src/utils/billing-gate.ts +0 -228
  241. package/src/utils/breadcrumb-buffer.ts +0 -18
  242. package/src/utils/cli-telemetry.test.ts +0 -153
  243. package/src/utils/cli-telemetry.ts +0 -61
  244. package/src/utils/cognito-session.machine.test.ts +0 -109
  245. package/src/utils/cognito-session.refresh.test.ts +0 -84
  246. package/src/utils/cognito-session.test.ts +0 -328
  247. package/src/utils/cognito-session.ts +0 -448
  248. package/src/utils/company-selection-error.test.ts +0 -42
  249. package/src/utils/company-selection-error.ts +0 -45
  250. package/src/utils/contribution-table.ts +0 -83
  251. package/src/utils/environmental-error.test.ts +0 -45
  252. package/src/utils/environmental-error.ts +0 -39
  253. package/src/utils/epipe.test.ts +0 -28
  254. package/src/utils/epipe.ts +0 -29
  255. package/src/utils/expected-cli-error.test.ts +0 -28
  256. package/src/utils/expected-cli-error.ts +0 -39
  257. package/src/utils/feedback-diagnostics.test.ts +0 -183
  258. package/src/utils/feedback-diagnostics.ts +0 -123
  259. package/src/utils/feedback-screenshots.test.ts +0 -134
  260. package/src/utils/feedback-screenshots.ts +0 -124
  261. package/src/utils/feedback-versions.test.ts +0 -98
  262. package/src/utils/feedback-versions.ts +0 -68
  263. package/src/utils/git.ts +0 -74
  264. package/src/utils/integrity.ts +0 -59
  265. package/src/utils/intercepted-process-exit.test.ts +0 -37
  266. package/src/utils/intercepted-process-exit.ts +0 -36
  267. package/src/utils/login-provider.test.ts +0 -42
  268. package/src/utils/login-provider.ts +0 -30
  269. package/src/utils/manifest.test.ts +0 -167
  270. package/src/utils/manifest.ts +0 -131
  271. package/src/utils/pack-contributions.test.ts +0 -603
  272. package/src/utils/pack-contributions.ts +0 -450
  273. package/src/utils/people.ts +0 -215
  274. package/src/utils/registry-client.ts +0 -204
  275. package/src/utils/registry.ts +0 -90
  276. package/src/utils/resolve-vault-credential.test.ts +0 -69
  277. package/src/utils/resolve-vault-credential.ts +0 -60
  278. package/src/utils/sandbox-runner-client.test.ts +0 -279
  279. package/src/utils/sandbox-runner-client.ts +0 -260
  280. package/src/utils/secrets-cache.ts +0 -183
  281. package/src/utils/sentry-identity.ts +0 -45
  282. package/src/utils/settle-with-timeout.test.ts +0 -21
  283. package/src/utils/settle-with-timeout.ts +0 -22
  284. package/src/utils/user-yaml-error.test.ts +0 -24
  285. package/src/utils/user-yaml-error.ts +0 -30
  286. package/src/utils/vault-api.test.ts +0 -536
  287. package/src/utils/vault-api.ts +0 -382
  288. package/src/utils/version-check.test.ts +0 -176
  289. package/src/utils/version-check.ts +0 -155
  290. package/src/utils/version-gate.test.ts +0 -1024
  291. package/src/utils/version-gate.ts +0 -702
  292. package/test/commands/db-tenant-isolation.test.ts +0 -94
  293. package/test/commands/db.test.ts +0 -85
  294. package/test/commands/signals.test.ts +0 -207
  295. package/test/commands/sources.test.ts +0 -231
  296. package/test/e2e/smoke-install-mcp.sh +0 -113
  297. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  298. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  299. package/test/fixtures/signals/action_item/sample.md +0 -16
  300. package/test/fixtures/signals/summary/sample.md +0 -12
  301. package/test/fixtures/sources/meetings/sample.md +0 -25
  302. package/test/helpers/cli-runner.ts +0 -150
  303. package/test/helpers/s3-list-mock.ts +0 -79
  304. package/test/helpers/vault-service-mock.ts +0 -295
  305. package/test/sources-signals/smoke.test.ts +0 -228
  306. package/tsconfig.json +0 -20
  307. package/vitest.config.ts +0 -11
  308. package/vitest.e2e.config.ts +0 -7
@@ -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
- }