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